diff --git a/docs/adr/0070-data-analytics-demo-polyglot-adoption.md b/docs/adr/0070-data-analytics-demo-polyglot-adoption.md index 1939b28..74da07d 100644 --- a/docs/adr/0070-data-analytics-demo-polyglot-adoption.md +++ b/docs/adr/0070-data-analytics-demo-polyglot-adoption.md @@ -1,8 +1,8 @@ # ADR-0070: Adopt polyglot (Python + TypeScript) for `packages/data-analytics-demo` — local-only SaaS customer-analytics demo -- Status: Accepted -- Date: 2026-05-17 -- Tags: architecture, polyglot, data-analytics, dbt, evidence, ollama, security, supply-chain +- Status: Accepted (amended 2026-05-18 — dashboard pivoted from Evidence to a self-built Python+Jinja2+Plotly generator; see "2026-05-18 amendment" section below) +- Date: 2026-05-17 (original), 2026-05-18 (amendment) +- Tags: architecture, polyglot, data-analytics, dbt, ollama, security, supply-chain - Companions: [ADR-0001](0001-monorepo-turborepo-pnpm.md) (the monorepo layout this ADR extends with a polyglot package) ## Context @@ -104,3 +104,34 @@ Rejected: **dbt-labs/jaffle-shop-template** — no LICENSE file in default branc - **TypeScript-only**: rejected — see Tradeoff 1. Cannot meet the quality bar with current TS data-analytics tooling. - **Separate repo (polyrepo)**: rejected — contradicts the monorepo decision in ADR-0001 and forfeits the "complex portfolio operated as a single deliverable" interview signal. - **Defer the demo**: rejected — the contract brief is live; deferring loses the matching window. + +## 2026-05-18 amendment — dashboard pivot + +The original Tradeoff 4 chose Evidence as the dashboard generator. Evidence is a high-quality OSS tool (MIT, evidence-dev/evidence, 6k+ stars) and the rationale stands on paper, but the integration cost in this monorepo turned out to be unbounded: + +- Evidence ships a SvelteKit-based build (`evidence build`) that requires its own flat `node_modules` for `@sveltejs/kit`, `vite`, `@evidence-dev/tailwind`, and several other transitive peers to be resolvable from generated template code. +- Pnpm 10's isolated layout and strict build-script approval gate broke this in three different ways on consumer Windows; each fix surfaced the next missing peer (chain of four+ peer-dep resolution failures locally before pivoting). +- The dashboard sits at the seam between the Python pipeline (data + dbt + ML + narrative) and the static HTML output. Adopting Evidence meant adopting a second package manager (pnpm or npm) inside an otherwise-Python sub-tree, with its own audit + Dependabot + CI surface. + +**Decision**: replace Evidence with a self-built Python+Jinja2+Plotly generator that lives entirely inside `src/data_analytics_demo/dashboard/`. Adds two PyPI deps (jinja2 BSD, plotly MIT — both well-known and already on the audit allowlist) and ships ~150 lines of code that read the same dbt marts and write static HTML to `dashboard/build/`. + +### Why this is the better fit + +- **Smaller blast radius**: 2 PyPI deps instead of 629 npm deps with the associated peer-dep tangle. Pip-audit covers the surface. +- **Single toolchain**: the dashboard now runs through the same Python venv, ruff, mypy, pytest gates as the rest of the package; no second package manager, no separate workflow. +- **Stronger portfolio signal**: "self-built static dashboard generator from synthetic SaaS marts" reads as analytics-engineering breadth; "I configured Evidence" reads as tool adoption. +- **Full layout control**: Plotly figures + Jinja2 templates give the demo the same chart types Evidence was going to produce (bar / scatter / line / area / heatmap / data table) without the SvelteKit indirection. + +### Tradeoff 4 (revised) + +| Option | Status | Why | +| ----------------------------------------- | -------------------- | ---------------------------------------------------------------------- | +| **Python + Jinja2 + Plotly (self-built)** | adopted | Single toolchain, 2 PyPI deps, full control, audit-clean | +| Evidence | rejected | Peer-dep chain unbounded in this monorepo; second toolchain added cost | +| Streamlit | rejected (unchanged) | Requires a Python server at view time; no static export | +| Quarto | rejected (unchanged) | BI focus weaker than the alternatives; CLI install required | +| Apache Superset | rejected (unchanged) | Full server with significant install overhead | + +### What the rest of this ADR still gets right + +Tradeoffs 1 (polyglot), 2 (DuckDB + Faker synthetic data), 3 (dbt), 5 (Ollama), and 6 (MetricFlow) are unchanged. The security mitigations (DuckDB ≥ 1.4.2 pin, pip-audit, Dependabot) and the polyglot CI structure carry over. diff --git a/packages/data-analytics-demo/Makefile b/packages/data-analytics-demo/Makefile index a2f49e4..c1f8bd0 100644 --- a/packages/data-analytics-demo/Makefile +++ b/packages/data-analytics-demo/Makefile @@ -36,8 +36,7 @@ narrative: $(PYTHON) -m data_analytics_demo.narrative.generate dashboard: - @echo "[dashboard] TODO T-09: Evidence dashboard not yet implemented" - @exit 1 + $(PYTHON) -m data_analytics_demo.dashboard.render semantic-validate: @echo "[semantic-validate] TODO T-10: MetricFlow validation not yet implemented" diff --git a/packages/data-analytics-demo/pyproject.toml b/packages/data-analytics-demo/pyproject.toml index e9f3e31..8b6f0b1 100644 --- a/packages/data-analytics-demo/pyproject.toml +++ b/packages/data-analytics-demo/pyproject.toml @@ -28,6 +28,9 @@ dependencies = [ # CLI + data validation "typer>=0.14", "pydantic>=2.9", + # Dashboard (self-built static HTML; replaces Evidence — see ADR-0070 amendment). + "jinja2>=3.1", + "plotly>=5.24", ] [project.optional-dependencies] @@ -74,7 +77,7 @@ mypy_path = "src" # but lags behind `pandas` releases; treating these as untyped is the # pragmatic choice for a Python 3.11 + pandas 3.x stack. [[tool.mypy.overrides]] -module = ["pandas", "pandas.*", "duckdb", "faker", "shap", "xgboost", "sklearn.*"] +module = ["pandas", "pandas.*", "duckdb", "faker", "shap", "xgboost", "sklearn.*", "plotly", "plotly.*"] ignore_missing_imports = true [tool.pytest.ini_options] diff --git a/packages/data-analytics-demo/src/data_analytics_demo/cli.py b/packages/data-analytics-demo/src/data_analytics_demo/cli.py index 1596e6e..8e66fbe 100644 --- a/packages/data-analytics-demo/src/data_analytics_demo/cli.py +++ b/packages/data-analytics-demo/src/data_analytics_demo/cli.py @@ -45,6 +45,15 @@ def ml() -> None: ) +@app.command() +def dashboard() -> None: + """Render the static HTML dashboard into dashboard/build/.""" + from data_analytics_demo.dashboard import render as dashboard_render + + out = dashboard_render.main() + typer.echo(f"wrote dashboard pages to {out}") + + @app.command() def narrative() -> None: """Generate an executive narrative from SHAP via local Ollama.""" diff --git a/packages/data-analytics-demo/src/data_analytics_demo/dashboard/__init__.py b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/__init__.py new file mode 100644 index 0000000..476a7ff --- /dev/null +++ b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/__init__.py @@ -0,0 +1,9 @@ +"""Self-built static-HTML dashboard generator (replaces Evidence per ADR-0070 amend). + +Reads marts from `warehouse/analytics.duckdb`, builds Plotly figures, and +renders Jinja2 templates into `dashboard/build/{index,rfm,churn,kpi}.html`. + +Pure Python — no npm, no SvelteKit, no peer-dep chains. Build is +single-process and reproducible via the same seed that feeds the data +generator. +""" diff --git a/packages/data-analytics-demo/src/data_analytics_demo/dashboard/charts.py b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/charts.py new file mode 100644 index 0000000..2dc5632 --- /dev/null +++ b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/charts.py @@ -0,0 +1,97 @@ +"""Plotly figure builders. Each function returns an HTML string ready to embed.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import plotly.express as px + +if TYPE_CHECKING: + import pandas as pd + +# CDN keeps the per-page HTML small (~10KB instead of 4MB inline plotly.js). +PLOTLY_JS_MODE = "cdn" + + +def _to_div(fig: object) -> str: + """Render a plotly figure as a div fragment (no ).""" + html: str = fig.to_html( # type: ignore[attr-defined] + include_plotlyjs=PLOTLY_JS_MODE, + full_html=False, + config={"displaylogo": False}, + ) + return html + + +def rfm_bar(df: pd.DataFrame) -> str: + fig = px.bar( + df, + x="rfm_segment", + y="customers", + text="customers", + title="Customers per RFM segment", + ) + fig.update_layout(xaxis_title="Segment", yaxis_title="Customers", height=400) + return _to_div(fig) + + +def rfm_scatter(df: pd.DataFrame) -> str: + fig = px.scatter( + df, + x="recency_days", + y="frequency_events", + color="rfm_segment", + size="monetary_usd", + hover_data=["customer_id"], + title="Recency × Frequency (size = monetary)", + ) + fig.update_layout( + xaxis_title="Recency (days; lower is better)", + yaxis_title="Frequency (event count)", + height=520, + ) + return _to_div(fig) + + +def churn_by_tier_bar(df: pd.DataFrame) -> str: + fig = px.bar( + df, + x="current_plan_tier", + y="churn_pct", + text="churn_pct", + title="Churn rate by plan tier", + ) + fig.update_layout(xaxis_title="Plan tier", yaxis_title="Churn %", height=400) + return _to_div(fig) + + +def signups_line(df: pd.DataFrame) -> str: + fig = px.line(df, x="month", y="signups", title="Monthly signups") + fig.update_layout(xaxis_title="Month", yaxis_title="New customers", height=400) + return _to_div(fig) + + +def paid_invoice_area(df: pd.DataFrame) -> str: + fig = px.area( + df, + x="month", + y="paid_amount_usd", + title="Paid invoice volume per month (USD)", + ) + fig.update_layout(xaxis_title="Month", yaxis_title="USD", height=400) + return _to_div(fig) + + +def cohort_heatmap(df: pd.DataFrame) -> str: + pivot = df.pivot_table( + index="cohort_month", columns="months_since_signup", values="retention_pct" + ) + fig = px.imshow( + pivot, + labels={"x": "Months since signup", "y": "Cohort month", "color": "Retention %"}, + title="Cohort retention heatmap", + color_continuous_scale="Blues", + aspect="auto", + ) + fig.update_layout(height=480) + return _to_div(fig) diff --git a/packages/data-analytics-demo/src/data_analytics_demo/dashboard/queries.py b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/queries.py new file mode 100644 index 0000000..78c4257 --- /dev/null +++ b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/queries.py @@ -0,0 +1,126 @@ +"""SQL queries against the dbt marts. + +Each function takes an open DuckDB connection and returns a DataFrame. +Centralising the SQL here keeps the templates focused on layout. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import duckdb + import pandas as pd + + +def _scalar(con: duckdb.DuckDBPyConnection, sql: str) -> float: + """Run a single-cell aggregate query and return the value (or 0 if empty).""" + row = con.execute(sql).fetchone() + if row is None: + return 0.0 + return float(row[0]) + + +def headline_metrics(con: duckdb.DuckDBPyConnection) -> dict[str, float]: + """Top-of-page numbers — customers, active rate, churn rate.""" + n_customers = _scalar(con, "select count(*) from customers") + active_rate = _scalar( + con, + "select coalesce(avg(case when status='active' then 1.0 else 0.0 end)*100, 0) " + "from subscriptions", + ) + churn_rate = _scalar( + con, "select coalesce(avg(is_churned)*100, 0) from churn_features" + ) + return { + "customers": int(n_customers), + "active_rate": round(active_rate, 1), + "churn_rate": round(churn_rate, 1), + } + + +def rfm_distribution(con: duckdb.DuckDBPyConnection) -> pd.DataFrame: + return con.execute( + """ + select rfm_segment, count(*) as customers, round(avg(monetary_usd), 0) as avg_monetary + from rfm_segments + group by rfm_segment + order by customers desc + """ + ).fetchdf() + + +def rfm_scatter(con: duckdb.DuckDBPyConnection) -> pd.DataFrame: + return con.execute( + """ + select customer_id, recency_days, frequency_events, monetary_usd, rfm_segment + from rfm_segments + """ + ).fetchdf() + + +def churn_by_tier(con: duckdb.DuckDBPyConnection) -> pd.DataFrame: + return con.execute( + """ + select + current_plan_tier, + count(*) as customers, + round(avg(is_churned)*100, 1) as churn_pct, + round(avg(events_last_30d), 1) as avg_events_30d + from churn_features + group by current_plan_tier + order by churn_pct desc + """ + ).fetchdf() + + +def churn_activity_buckets(con: duckdb.DuckDBPyConnection) -> pd.DataFrame: + return con.execute( + """ + select + case + when recent_to_lifetime_ratio is null then 'no activity' + when recent_to_lifetime_ratio < 0.3 then '0.0 – 0.3 (slowing)' + when recent_to_lifetime_ratio < 0.7 then '0.3 – 0.7' + when recent_to_lifetime_ratio < 1.5 then '0.7 – 1.5 (steady)' + else '1.5+ (accelerating)' + end as activity_bucket, + count(*) as customers, + round(avg(is_churned)*100, 1) as churn_pct + from churn_features + group by activity_bucket + order by churn_pct desc + """ + ).fetchdf() + + +def monthly_signups(con: duckdb.DuckDBPyConnection) -> pd.DataFrame: + return con.execute( + """ + select date_trunc('month', signup_date) as month, count(*) as signups + from customers + group by 1 order by 1 + """ + ).fetchdf() + + +def monthly_paid_invoice_volume(con: duckdb.DuckDBPyConnection) -> pd.DataFrame: + return con.execute( + """ + select date_trunc('month', period_start) as month, + sum(amount_usd) as paid_amount_usd + from invoices + where status = 'paid' + group by 1 order by 1 + """ + ).fetchdf() + + +def cohort_retention_grid(con: duckdb.DuckDBPyConnection) -> pd.DataFrame: + return con.execute( + """ + select cohort_month, months_since_signup, retention_pct + from cohort_retention + order by cohort_month, months_since_signup + """ + ).fetchdf() diff --git a/packages/data-analytics-demo/src/data_analytics_demo/dashboard/render.py b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/render.py new file mode 100644 index 0000000..b0aa3b9 --- /dev/null +++ b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/render.py @@ -0,0 +1,134 @@ +"""Self-built static-HTML dashboard renderer. + +Entry point for `make dashboard` and `data-analytics-demo dashboard`. Reads +the dbt marts produced by `make dbt`, builds Plotly figures, and writes +`dashboard/build/{index,rfm,churn,kpi}.html`. +""" + +from __future__ import annotations + +import sys +from datetime import UTC, datetime +from pathlib import Path + +import duckdb +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from ..ml import _io +from . import charts, queries + +PAGE_NAMES = ("index", "rfm", "churn", "kpi") + + +def _emit(msg: str) -> None: + print(f"[dashboard] {msg}", file=sys.stderr, flush=True) # noqa: T201 + + +def _templates_dir() -> Path: + return Path(__file__).resolve().parent / "templates" + + +def _default_build_dir() -> Path: + return _io.package_root() / "dashboard" / "build" + + +def _build_env() -> Environment: + return Environment( + loader=FileSystemLoader(_templates_dir()), + autoescape=select_autoescape(["html", "html.j2"]), + trim_blocks=True, + lstrip_blocks=True, + ) + + +def _common_context(con: duckdb.DuckDBPyConnection) -> dict[str, object]: + return { + "generated_at": datetime.now(UTC).isoformat(timespec="seconds"), + "headline": queries.headline_metrics(con), + "pages": PAGE_NAMES, + } + + +def main( + *, + duckdb_path: Path | None = None, + build_dir: Path | None = None, +) -> Path: + """Render every page; return the build directory path.""" + db = duckdb_path or _io.default_warehouse_path() + if not db.exists(): + raise FileNotFoundError( + f"warehouse not found at {db}. " + "Run `make data` and `make dbt` before `make dashboard`." + ) + out = build_dir or _default_build_dir() + out.mkdir(parents=True, exist_ok=True) + + _emit(f"reading marts from {db}") + con = duckdb.connect(str(db)) + try: + ctx = _common_context(con) + + _emit("rendering index.html") + rfm_bar_html = charts.rfm_bar(queries.rfm_distribution(con)) + churn_bar_html = charts.churn_by_tier_bar(queries.churn_by_tier(con)) + signups_html = charts.signups_line(queries.monthly_signups(con)) + _write( + out / "index.html", + "index.html.j2", + {**ctx, "rfm_bar": rfm_bar_html, "churn_bar": churn_bar_html, "signups": signups_html}, + ) + + _emit("rendering rfm.html") + _write( + out / "rfm.html", + "rfm.html.j2", + { + **ctx, + "rfm_bar": charts.rfm_bar(queries.rfm_distribution(con)), + "rfm_scatter": charts.rfm_scatter(queries.rfm_scatter(con)), + "rfm_table": queries.rfm_distribution(con).to_html( + index=False, classes="data-table" + ), + }, + ) + + _emit("rendering churn.html") + _write( + out / "churn.html", + "churn.html.j2", + { + **ctx, + "churn_bar": charts.churn_by_tier_bar(queries.churn_by_tier(con)), + "buckets_table": queries.churn_activity_buckets(con).to_html( + index=False, classes="data-table" + ), + }, + ) + + _emit("rendering kpi.html") + _write( + out / "kpi.html", + "kpi.html.j2", + { + **ctx, + "signups": charts.signups_line(queries.monthly_signups(con)), + "paid_area": charts.paid_invoice_area(queries.monthly_paid_invoice_volume(con)), + "cohort_heatmap": charts.cohort_heatmap(queries.cohort_retention_grid(con)), + }, + ) + finally: + con.close() + + _emit(f"done — {len(PAGE_NAMES)} pages in {out}") + return out + + +def _write(path: Path, template_name: str, context: dict[str, object]) -> None: + env = _build_env() + template = env.get_template(template_name) + path.write_text(template.render(**context), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/base.html.j2 b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/base.html.j2 new file mode 100644 index 0000000..5aa830e --- /dev/null +++ b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/base.html.j2 @@ -0,0 +1,87 @@ + + + + + + {% block title %}data-analytics-demo{% endblock %} + + + +
+

data-analytics-demo

+ +
+
+
+
Customers
{{ headline.customers }}
+
Active subscriptions
{{ headline.active_rate }}%
+
Churn rate
{{ headline.churn_rate }}%
+
+ {% block content %}{% endblock %} +
+ + + diff --git a/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/churn.html.j2 b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/churn.html.j2 new file mode 100644 index 0000000..dd8d66f --- /dev/null +++ b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/churn.html.j2 @@ -0,0 +1,13 @@ +{% extends "base.html.j2" %} +{% set current_page = "churn" %} +{% block title %}Churn risk · data-analytics-demo{% endblock %} +{% block content %} +

Churn risk

+

Built from the churn_features mart. The engineered signal is the trailing-30-day vs lifetime-daily-average event ratio — customers with a ratio < 1 are slowing down.

+ +

Churn rate by plan tier

+
{{ churn_bar|safe }}
+ +

Trailing-30d activity vs churn label

+
{{ buckets_table|safe }}
+{% endblock %} diff --git a/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/index.html.j2 b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/index.html.j2 new file mode 100644 index 0000000..b9e5928 --- /dev/null +++ b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/index.html.j2 @@ -0,0 +1,22 @@ +{% extends "base.html.j2" %} +{% set current_page = "index" %} +{% block title %}Overview · data-analytics-demo{% endblock %} +{% block content %} +

Customer-analytics overview. Synthetic SaaS data only. The full pipeline (make demo) regenerates everything from scratch with a deterministic seed.

+ +

RFM segment distribution

+
{{ rfm_bar|safe }}
+ +

Churn rate by plan tier

+
{{ churn_bar|safe }}
+ +

Monthly signups

+
{{ signups|safe }}
+ +

Pages

+ +{% endblock %} diff --git a/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/kpi.html.j2 b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/kpi.html.j2 new file mode 100644 index 0000000..38dc1cc --- /dev/null +++ b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/kpi.html.j2 @@ -0,0 +1,16 @@ +{% extends "base.html.j2" %} +{% set current_page = "kpi" %} +{% block title %}KPI overview · data-analytics-demo{% endblock %} +{% block content %} +

KPI overview

+

Operational headlines drawn directly from the dbt marts.

+ +

Monthly signups

+
{{ signups|safe }}
+ +

Paid invoice volume

+
{{ paid_area|safe }}
+ +

Cohort retention

+
{{ cohort_heatmap|safe }}
+{% endblock %} diff --git a/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/rfm.html.j2 b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/rfm.html.j2 new file mode 100644 index 0000000..f9c3b3e --- /dev/null +++ b/packages/data-analytics-demo/src/data_analytics_demo/dashboard/templates/rfm.html.j2 @@ -0,0 +1,16 @@ +{% extends "base.html.j2" %} +{% set current_page = "rfm" %} +{% block title %}RFM segmentation · data-analytics-demo{% endblock %} +{% block content %} +

RFM segmentation

+

Customers grouped by Recency / Frequency / Monetary quintiles. Source mart: rfm_segments.

+ +

Segment distribution

+
{{ rfm_bar|safe }}
+ +

Recency × Frequency × Monetary

+
{{ rfm_scatter|safe }}
+ +

Per-segment economics

+
{{ rfm_table|safe }}
+{% endblock %} diff --git a/packages/data-analytics-demo/tests/test_dashboard.py b/packages/data-analytics-demo/tests/test_dashboard.py new file mode 100644 index 0000000..92ecd6a --- /dev/null +++ b/packages/data-analytics-demo/tests/test_dashboard.py @@ -0,0 +1,100 @@ +"""Tests for the self-built dashboard generator (T-09 / AC-5.1〜5.4).""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from data_analytics_demo.dashboard import render +from data_analytics_demo.data import generate + +try: + from dbt.cli.main import dbtRunner + + DBT_AVAILABLE = True +except ImportError: + DBT_AVAILABLE = False + + +def _materialize_marts(tmp_path: Path) -> Path: + duckdb_path = tmp_path / "analytics.duckdb" + generate.main( + n_customers=300, + n_subscriptions=600, + n_events=6_000, + n_invoices=900, + seed=42, + output_path=duckdb_path, + ) + pkg_root = Path(__file__).resolve().parent.parent + dbt_dir = pkg_root / "dbt_project" + os.environ["DBT_DUCKDB_PATH"] = str(duckdb_path) + runner = dbtRunner() + result = runner.invoke( + [ + "run", + "--project-dir", + str(dbt_dir), + "--profiles-dir", + str(dbt_dir), + "--quiet", + ] + ) + if not result.success: + raise RuntimeError(f"dbt run failed: {result.exception}") + return duckdb_path + + +@pytest.fixture(scope="module") +def materialized_warehouse(tmp_path_factory: pytest.TempPathFactory) -> Path: + if not DBT_AVAILABLE: + pytest.skip("dbt not importable") + return _materialize_marts(tmp_path_factory.mktemp("dashboard")) + + +# ---- AC-5.1: `make dashboard` produces static HTML ------------------------ + +def test_ac_5_1_renders_all_pages(materialized_warehouse: Path, tmp_path: Path) -> None: + out = tmp_path / "build" + written = render.main(duckdb_path=materialized_warehouse, build_dir=out) + assert written == out + for page in ("index", "rfm", "churn", "kpi"): + path = out / f"{page}.html" + assert path.exists(), f"missing {page}.html" + content = path.read_text(encoding="utf-8") + assert content.startswith(""), f"{page}.html not valid HTML" + + +# ---- AC-5.2: ≥ 3 sections in output --------------------------------------- + +def test_ac_5_2_index_includes_required_sections( + materialized_warehouse: Path, tmp_path: Path +) -> None: + out = tmp_path / "build" + render.main(duckdb_path=materialized_warehouse, build_dir=out) + index = (out / "index.html").read_text(encoding="utf-8") + assert "RFM segment distribution" in index + assert "Churn rate by plan tier" in index + assert "Monthly signups" in index + + +# ---- AC-5.3: connects to same analytics.duckdb ---------------------------- + +def test_ac_5_3_uses_provided_duckdb(materialized_warehouse: Path, tmp_path: Path) -> None: + out = tmp_path / "build" + render.main(duckdb_path=materialized_warehouse, build_dir=out) + # Headline metric block reflects the seeded data sizing (300 customers). + index = (out / "index.html").read_text(encoding="utf-8") + assert ">300<" in index, "customer count should match seeded data" + + +# ---- AC-5.4: missing duckdb → fail with clear error ---------------------- + +def test_ac_5_4_missing_warehouse_raises(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError, match="warehouse not found"): + render.main( + duckdb_path=tmp_path / "does_not_exist.duckdb", + build_dir=tmp_path / "build", + )