You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/03/31 15:54:05 UTC

(superset) branch master updated: feat(mcp): add Big Number chart type support to MCP service (#38403)

This is an automated email from the ASF dual-hosted git repository.

misterg pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 42457208512 feat(mcp): add Big Number chart type support to MCP service (#38403)
42457208512 is described below

commit 4245720851240a896556670004b2bc6171d5d060
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Tue Mar 31 17:53:53 2026 +0200

    feat(mcp): add Big Number chart type support to MCP service (#38403)
    
    Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
---
 superset/mcp_service/app.py                        |   3 +
 superset/mcp_service/chart/chart_utils.py          |  88 +++-
 superset/mcp_service/chart/preview_utils.py        |  12 +-
 superset/mcp_service/chart/schemas.py              | 113 ++++-
 superset/mcp_service/chart/tool/generate_chart.py  |  12 +-
 .../chart/validation/schema_validator.py           |  89 ++++
 .../mcp_service/chart/test_big_number_chart.py     | 524 +++++++++++++++++++++
 7 files changed, 835 insertions(+), 6 deletions(-)

diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index 3fb388f8aaf..4b05b5d7184 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -118,6 +118,9 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link:
 - chart_type="xy", kind="bar": Bar chart for category comparison
 - chart_type="xy", kind="area": Area chart for volume visualization
 - chart_type="xy", kind="scatter": Scatter plot for correlation analysis
+- chart_type="big_number": Big Number display (single metric, header only)
+- chart_type="big_number", show_trendline=True,
+  temporal_column="<date_col>": Big Number with trendline
 - chart_type="table": Data table for detailed views
 - chart_type="table", viz_type="ag-grid-table": Interactive AG Grid table
 - chart_type="pie": Pie chart for proportional data (set donut=True for donut)
diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py
index 0a4b19b0cee..62704a0b5ef 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -27,6 +27,7 @@ from dataclasses import dataclass
 from typing import Any, Dict
 
 from superset.mcp_service.chart.schemas import (
+    BigNumberChartConfig,
     ChartCapabilities,
     ChartSemantics,
     ColumnRef,
@@ -311,7 +312,8 @@ def map_config_to_form_data(
     | PieChartConfig
     | PivotTableChartConfig
     | MixedTimeseriesChartConfig
-    | HandlebarsChartConfig,
+    | HandlebarsChartConfig
+    | BigNumberChartConfig,
     dataset_id: int | str | None = None,
 ) -> Dict[str, Any]:
     """Map chart config to Superset form_data."""
@@ -327,6 +329,14 @@ def map_config_to_form_data(
         return map_mixed_timeseries_config(config, dataset_id=dataset_id)
     elif isinstance(config, HandlebarsChartConfig):
         return map_handlebars_config(config)
+    elif isinstance(config, BigNumberChartConfig):
+        if config.show_trendline and config.temporal_column:
+            if not is_column_truly_temporal(config.temporal_column, dataset_id):
+                raise ValueError(
+                    f"Big Number trendline requires a temporal SQL column; "
+                    f"'{config.temporal_column}' is not temporal."
+                )
+        return map_big_number_config(config)
     else:
         raise ValueError(f"Unsupported config type: {type(config)}")
 
@@ -650,6 +660,46 @@ def map_pie_config(config: PieChartConfig) -> Dict[str, Any]:
     return form_data
 
 
+def map_big_number_config(config: BigNumberChartConfig) -> Dict[str, Any]:
+    """Map big number chart config to Superset form_data."""
+    # Determine viz_type: big_number (with trendline) or big_number_total
+    if config.show_trendline and config.temporal_column:
+        viz_type = "big_number"
+    else:
+        viz_type = "big_number_total"
+
+    metric = create_metric_object(config.metric)
+    form_data: Dict[str, Any] = {
+        "viz_type": viz_type,
+        "metric": metric,
+    }
+
+    if config.subheader:
+        form_data["subheader"] = config.subheader
+
+    if config.y_axis_format:
+        form_data["y_axis_format"] = config.y_axis_format
+
+    # Trendline-specific fields
+    if viz_type == "big_number":
+        # Big Number with trendline uses granularity_sqla for the temporal column
+        # (unlike XY charts which use x_axis). This is how Superset's
+        # big_number viz determines the time column for the trendline.
+        form_data["granularity_sqla"] = config.temporal_column
+        form_data["show_trend_line"] = True
+        form_data["start_y_axis_at_zero"] = config.start_y_axis_at_zero
+
+        if config.time_grain:
+            form_data["time_grain_sqla"] = config.time_grain
+
+        if config.compare_lag is not None:
+            form_data["compare_lag"] = config.compare_lag
+
+    _add_adhoc_filters(form_data, config.filters)
+
+    return form_data
+
+
 def map_handlebars_config(config: HandlebarsChartConfig) -> Dict[str, Any]:
     """Map handlebars chart config to Superset form_data."""
     form_data: Dict[str, Any] = {
@@ -975,13 +1025,31 @@ def _handlebars_chart_what(config: HandlebarsChartConfig) -> str:
     return "Handlebars Chart"
 
 
+def _big_number_chart_what(config: BigNumberChartConfig) -> str:
+    """Build the 'what' portion for a big number chart name.
+
+    Uses parentheses instead of en-dash to avoid collision with
+    ``generate_chart_name``'s ``\u2013`` context separator.
+    """
+    if config.metric.label:
+        metric_label = config.metric.label
+    elif config.metric.aggregate:
+        metric_label = f"{config.metric.aggregate}({config.metric.name})"
+    else:
+        metric_label = config.metric.name
+    if config.show_trendline:
+        return f"Big Number ({metric_label}, trendline)"
+    return f"Big Number ({metric_label})"
+
+
 def generate_chart_name(
     config: TableChartConfig
     | XYChartConfig
     | PieChartConfig
     | PivotTableChartConfig
     | MixedTimeseriesChartConfig
-    | HandlebarsChartConfig,
+    | HandlebarsChartConfig
+    | BigNumberChartConfig,
     dataset_name: str | None = None,
 ) -> str:
     """Generate a descriptive chart name following a standard format.
@@ -1015,6 +1083,9 @@ def generate_chart_name(
     elif isinstance(config, HandlebarsChartConfig):
         what = _handlebars_chart_what(config)
         context = _summarize_filters(getattr(config, "filters", None))
+    elif isinstance(config, BigNumberChartConfig):
+        what = _big_number_chart_what(config)
+        context = _summarize_filters(getattr(config, "filters", None))
     else:
         return "Chart"
 
@@ -1046,6 +1117,12 @@ def _resolve_viz_type(config: Any) -> str:
         return "mixed_timeseries"
     elif chart_type == "handlebars":
         return "handlebars"
+    elif chart_type == "big_number":
+        show_trendline = getattr(config, "show_trendline", False)
+        temporal_column = getattr(config, "temporal_column", None)
+        return (
+            "big_number" if show_trendline and temporal_column else "big_number_total"
+        )
     return "unknown"
 
 
@@ -1129,6 +1206,13 @@ def analyze_chart_semantics(chart: Any | None, config: Any) -> ChartSemantics:
             "Renders data using a custom Handlebars HTML template for "
             "fully flexible layouts like KPI cards, leaderboards, and reports"
         ),
+        "big_number": (
+            "Displays a key metric with a trendline showing "
+            "how the value changes over time"
+        ),
+        "big_number_total": (
+            "Highlights a single key metric value as a prominent number"
+        ),
     }
 
     primary_insight = insights_map.get(
diff --git a/superset/mcp_service/chart/preview_utils.py b/superset/mcp_service/chart/preview_utils.py
index dcc8642f000..d46865c9d13 100644
--- a/superset/mcp_service/chart/preview_utils.py
+++ b/superset/mcp_service/chart/preview_utils.py
@@ -92,13 +92,23 @@ def generate_preview_from_form_data(
         query_filters = adhoc_filters_to_query_filters(
             form_data.get("adhoc_filters", [])
         )
+
+        # Big Number charts use singular "metric" instead of "metrics"
+        metrics = form_data.get("metrics", [])
+        if not metrics and form_data.get("metric"):
+            metrics = [form_data["metric"]]
+
+        # Big Number with trendline uses granularity_sqla as the time column
+        if not columns and form_data.get("granularity_sqla"):
+            columns = [form_data["granularity_sqla"]]
+
         factory = QueryContextFactory()
         query_context_obj = factory.create(
             datasource={"id": dataset_id, "type": "table"},
             queries=[
                 {
                     "columns": columns,
-                    "metrics": form_data.get("metrics", []),
+                    "metrics": metrics,
                     "orderby": form_data.get("orderby", []),
                     "row_limit": form_data.get("row_limit", 100),
                     "filters": query_filters,
diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py
index 2da6c3ef804..ce7d1ea397f 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -36,6 +36,7 @@ from pydantic import (
     model_validator,
     PositiveInt,
 )
+from typing_extensions import Self
 
 from superset.constants import TimeGrain
 from superset.daos.base import ColumnOperator, ColumnOperatorEnum
@@ -851,6 +852,112 @@ class HandlebarsChartConfig(UnknownFieldCheckMixin):
         return self
 
 
+class BigNumberChartConfig(UnknownFieldCheckMixin):
+    model_config = ConfigDict(extra="ignore")
+
+    chart_type: Literal["big_number"] = Field(
+        ...,
+        description=(
+            "Chart type discriminator - MUST be 'big_number'. "
+            "Creates Big Number charts that display a single prominent "
+            "metric value. Set show_trendline=True with a temporal_column "
+            "for a number with trendline, or leave show_trendline=False "
+            "for a standalone number."
+        ),
+    )
+    metric: ColumnRef = Field(
+        ...,
+        description=(
+            "The metric to display as a big number. "
+            "Must include an aggregate function (e.g., SUM, COUNT)."
+        ),
+    )
+    temporal_column: str | None = Field(
+        None,
+        description=(
+            "Temporal column for the trendline x-axis. "
+            "Required when show_trendline is True."
+        ),
+        min_length=1,
+        max_length=255,
+        pattern=r"^[a-zA-Z0-9_][a-zA-Z0-9_\s\-\.]*$",
+    )
+    time_grain: TimeGrain | None = Field(
+        None,
+        description=(
+            "Time granularity for trendline data. "
+            "Common values: PT1H (hour), P1D (day), P1W (week), "
+            "P1M (month), P1Y (year)."
+        ),
+    )
+    show_trendline: bool = Field(
+        False,
+        description=(
+            "Show a trendline below the big number. "
+            "Requires 'temporal_column' to be set."
+        ),
+    )
+    subheader: str | None = Field(
+        None,
+        description="Subtitle text displayed below the big number",
+        max_length=500,
+    )
+    y_axis_format: str | None = Field(
+        None,
+        description=(
+            "Number format string for the metric value "
+            "(e.g., '$,.2f' for currency, ',.0f' for integers, "
+            "'.2%' for percentages)"
+        ),
+        max_length=50,
+    )
+    start_y_axis_at_zero: bool = Field(
+        True,
+        description="Anchor trendline y-axis at zero",
+    )
+    compare_lag: int | None = Field(
+        None,
+        description=(
+            "Number of time periods to compare against. "
+            "Displays a percentage change vs the prior period."
+        ),
+        ge=1,
+    )
+    filters: list[FilterConfig] | None = Field(
+        None,
+        description="Filters to apply",
+    )
+
+    @model_validator(mode="after")
+    def validate_trendline_fields(self) -> Self:
+        """Validate trendline requires temporal column."""
+        if self.show_trendline and not self.temporal_column:
+            raise ValueError(
+                "Big Number chart with show_trendline=True requires "
+                "'temporal_column'. Specify a date/time column for "
+                "the trendline x-axis."
+            )
+        if self.compare_lag and not self.show_trendline:
+            raise ValueError(
+                "compare_lag requires show_trendline=True. "
+                "Period comparison is only available for "
+                "trendline charts."
+            )
+        return self
+
+    @model_validator(mode="after")
+    def validate_metric_aggregate(self) -> Self:
+        """Ensure metric is a valid metric reference (aggregate or saved)."""
+        if not self.metric.is_metric:
+            raise ValueError(
+                "Big Number metric must be either a saved dataset metric "
+                "or include an aggregate function (e.g., SUM, COUNT, AVG). "
+                "Set 'saved_metric': true to use a saved metric, or add "
+                "'aggregate' to the metric specification."
+            )
+        return self
+
+
 class TableChartConfig(UnknownFieldCheckMixin):
     model_config = ConfigDict(extra="ignore", populate_by_name=True)
 
@@ -1015,12 +1122,14 @@ ChartConfig = Annotated[
     | PieChartConfig
     | PivotTableChartConfig
     | MixedTimeseriesChartConfig
-    | HandlebarsChartConfig,
+    | HandlebarsChartConfig
+    | BigNumberChartConfig,
     Field(
         discriminator="chart_type",
         description=(
             "Chart configuration - specify chart_type as 'xy', 'table', "
-            "'pie', 'pivot_table', 'mixed_timeseries', or 'handlebars'"
+            "'pie', 'pivot_table', 'mixed_timeseries', 'handlebars', "
+            "or 'big_number'"
         ),
     ),
 ]
diff --git a/superset/mcp_service/chart/tool/generate_chart.py b/superset/mcp_service/chart/tool/generate_chart.py
index c3cc82cf7bb..095e74419b3 100644
--- a/superset/mcp_service/chart/tool/generate_chart.py
+++ b/superset/mcp_service/chart/tool/generate_chart.py
@@ -89,13 +89,23 @@ def _compile_chart(
         query_filters = adhoc_filters_to_query_filters(
             form_data.get("adhoc_filters", [])
         )
+
+        # Big Number charts use singular "metric" instead of "metrics"
+        metrics = form_data.get("metrics", [])
+        if not metrics and form_data.get("metric"):
+            metrics = [form_data["metric"]]
+
+        # Big Number with trendline uses granularity_sqla as the time column
+        if not columns and form_data.get("granularity_sqla"):
+            columns = [form_data["granularity_sqla"]]
+
         factory = QueryContextFactory()
         query_context = factory.create(
             datasource={"id": dataset_id, "type": "table"},
             queries=[
                 {
                     "columns": columns,
-                    "metrics": form_data.get("metrics", []),
+                    "metrics": metrics,
                     "orderby": form_data.get("orderby", []),
                     "row_limit": 2,
                     "filters": query_filters,
diff --git a/superset/mcp_service/chart/validation/schema_validator.py b/superset/mcp_service/chart/validation/schema_validator.py
index 07ecb2b912b..c82728b11bd 100644
--- a/superset/mcp_service/chart/validation/schema_validator.py
+++ b/superset/mcp_service/chart/validation/schema_validator.py
@@ -134,6 +134,7 @@ class SchemaValidator:
                     "Add 'chart_type': 'pivot_table' for interactive pivot tables",
                     "Add 'chart_type': 'mixed_timeseries' for dual-series time charts",
                     "Add 'chart_type': 'handlebars' for custom HTML template charts",
+                    "Add 'chart_type': 'big_number' for big number display",
                     "Example: 'config': {'chart_type': 'xy', ...}",
                 ],
                 error_code="MISSING_CHART_TYPE",
@@ -154,6 +155,7 @@ class SchemaValidator:
             "pivot_table": SchemaValidator._pre_validate_pivot_table_config,
             "mixed_timeseries": SchemaValidator._pre_validate_mixed_timeseries_config,
             "handlebars": SchemaValidator._pre_validate_handlebars_config,
+            "big_number": SchemaValidator._pre_validate_big_number_config,
         }
 
         if not isinstance(chart_type, str) or chart_type not in chart_type_validators:
@@ -170,6 +172,7 @@ class SchemaValidator:
                     "Use 'chart_type': 'pivot_table' for interactive pivot tables",
                     "Use 'chart_type': 'mixed_timeseries' for dual-series time charts",
                     "Use 'chart_type': 'handlebars' for custom HTML template charts",
+                    "Use 'chart_type': 'big_number' for big number display",
                     "Check spelling and ensure lowercase",
                 ],
                 error_code="INVALID_CHART_TYPE",
@@ -362,6 +365,75 @@ class SchemaValidator:
 
         return True, None
 
+    @staticmethod
+    def _pre_validate_big_number_config(
+        config: Dict[str, Any],
+    ) -> Tuple[bool, ChartGenerationError | None]:
+        """Pre-validate big number chart configuration."""
+        if "metric" not in config:
+            return False, ChartGenerationError(
+                error_type="missing_metric",
+                message="Big Number chart missing required field: metric",
+                details="Big Number charts require a 'metric' field "
+                "specifying the value to display",
+                suggestions=[
+                    "Add 'metric' with name and aggregate: "
+                    "{'name': 'revenue', 'aggregate': 'SUM'}",
+                    "The aggregate function is required (SUM, COUNT, AVG, MIN, MAX)",
+                    "Example: {'chart_type': 'big_number', "
+                    "'metric': {'name': 'sales', 'aggregate': 'SUM'}}",
+                ],
+                error_code="MISSING_BIG_NUMBER_METRIC",
+            )
+
+        metric = config.get("metric", {})
+        if not isinstance(metric, dict):
+            return False, ChartGenerationError(
+                error_type="invalid_metric_type",
+                message="Big Number metric must be a dict with 'name' and 'aggregate'",
+                details="The 'metric' field must be an object, "
+                f"got {type(metric).__name__}",
+                suggestions=[
+                    "Use a dict: {'name': 'col', 'aggregate': 'SUM'}",
+                    "Valid aggregates: SUM, COUNT, AVG, MIN, MAX",
+                ],
+                error_code="INVALID_BIG_NUMBER_METRIC_TYPE",
+            )
+        if not metric.get("aggregate") and not metric.get("saved_metric"):
+            return False, ChartGenerationError(
+                error_type="missing_metric_aggregate",
+                message="Big Number metric must include an aggregate function "
+                "or reference a saved metric",
+                details="The metric must have an 'aggregate' field "
+                "or 'saved_metric': true",
+                suggestions=[
+                    "Add 'aggregate' to your metric: "
+                    "{'name': 'col', 'aggregate': 'SUM'}",
+                    "Or use a saved metric: "
+                    "{'name': 'total_sales', 'saved_metric': true}",
+                    "Valid aggregates: SUM, COUNT, AVG, MIN, MAX",
+                ],
+                error_code="MISSING_BIG_NUMBER_AGGREGATE",
+            )
+
+        show_trendline = config.get("show_trendline", False)
+        temporal_column = config.get("temporal_column")
+        if show_trendline and not temporal_column:
+            return False, ChartGenerationError(
+                error_type="missing_temporal_column",
+                message="Trendline requires a temporal column",
+                details="When 'show_trendline' is True, a "
+                "'temporal_column' must be specified",
+                suggestions=[
+                    "Add 'temporal_column': 'date_column_name'",
+                    "Or set 'show_trendline': false for number only",
+                    "Use get_dataset_info to find temporal columns",
+                ],
+                error_code="MISSING_TEMPORAL_COLUMN",
+            )
+
+        return True, None
+
     @staticmethod
     def _pre_validate_pivot_table_config(
         config: Dict[str, Any],
@@ -526,6 +598,23 @@ class SchemaValidator:
                         ],
                         error_code="HANDLEBARS_VALIDATION_ERROR",
                     )
+                elif chart_type == "big_number":
+                    return ChartGenerationError(
+                        error_type="big_number_validation_error",
+                        message="Big Number chart configuration validation failed",
+                        details="The Big Number chart configuration is "
+                        "missing required fields or has invalid "
+                        "structure",
+                        suggestions=[
+                            "Ensure 'metric' field has 'name' and 'aggregate'",
+                            "Example: 'metric': {'name': 'revenue', "
+                            "'aggregate': 'SUM'}",
+                            "For trendline: add 'show_trendline': true "
+                            "and 'temporal_column': 'date_col'",
+                            "Without trendline: just provide the metric",
+                        ],
+                        error_code="BIG_NUMBER_VALIDATION_ERROR",
+                    )
 
         # Default enhanced error
         error_details = []
diff --git a/tests/unit_tests/mcp_service/chart/test_big_number_chart.py b/tests/unit_tests/mcp_service/chart/test_big_number_chart.py
new file mode 100644
index 00000000000..59e142333bd
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_big_number_chart.py
@@ -0,0 +1,524 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for Big Number chart type support in MCP service."""
+
+import pytest
+from pydantic import ValidationError
+
+from superset.mcp_service.chart.chart_utils import (
+    _resolve_viz_type,
+    analyze_chart_capabilities,
+    analyze_chart_semantics,
+    generate_chart_name,
+    map_big_number_config,
+    map_config_to_form_data,
+)
+from superset.mcp_service.chart.schemas import (
+    BigNumberChartConfig,
+    ColumnRef,
+    FilterConfig,
+)
+from superset.mcp_service.chart.validation.schema_validator import (
+    SchemaValidator,
+)
+
+
+class TestBigNumberChartConfig:
+    """Test BigNumberChartConfig Pydantic schema."""
+
+    def test_minimal_config(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+        )
+        assert config.chart_type == "big_number"
+        assert config.metric.name == "revenue"
+        assert config.metric.aggregate == "SUM"
+        assert config.show_trendline is False
+
+    def test_with_trendline(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            temporal_column="ds",
+            show_trendline=True,
+        )
+        assert config.show_trendline is True
+        assert config.temporal_column == "ds"
+
+    def test_trendline_without_temporal_column_fails(self) -> None:
+        with pytest.raises(ValidationError, match="requires 'temporal_column'"):
+            BigNumberChartConfig(
+                chart_type="big_number",
+                metric=ColumnRef(name="revenue", aggregate="SUM"),
+                show_trendline=True,
+            )
+
+    def test_metric_without_aggregate_fails(self) -> None:
+        with pytest.raises(ValidationError, match="saved dataset metric"):
+            BigNumberChartConfig(
+                chart_type="big_number",
+                metric=ColumnRef(name="revenue"),
+            )
+
+    def test_saved_metric_accepted(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="total_sales", saved_metric=True),
+        )
+        assert config.metric.saved_metric is True
+        assert config.metric.is_metric is True
+
+    def test_saved_metric_passes_pre_validation(self) -> None:
+        """Verify saved metrics pass through SchemaValidator pre-validation."""
+        data = {
+            "chart_type": "big_number",
+            "metric": {"name": "total_sales", "saved_metric": True},
+        }
+        is_valid, error = SchemaValidator._pre_validate_big_number_config(data)
+        assert is_valid is True
+        assert error is None
+
+    def test_with_subheader(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            subheader="Total revenue this quarter",
+        )
+        assert config.subheader == "Total revenue this quarter"
+
+    def test_with_y_axis_format(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            y_axis_format="$,.2f",
+        )
+        assert config.y_axis_format == "$,.2f"
+
+    def test_with_compare_lag(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            temporal_column="ds",
+            show_trendline=True,
+            compare_lag=1,
+        )
+        assert config.compare_lag == 1
+
+    def test_compare_lag_zero_fails(self) -> None:
+        with pytest.raises(ValidationError, match="greater than or equal"):
+            BigNumberChartConfig(
+                chart_type="big_number",
+                metric=ColumnRef(name="revenue", aggregate="SUM"),
+                compare_lag=0,
+            )
+
+    def test_compare_lag_requires_trendline(self) -> None:
+        with pytest.raises(
+            ValidationError, match="compare_lag requires show_trendline"
+        ):
+            BigNumberChartConfig(
+                chart_type="big_number",
+                metric=ColumnRef(name="revenue", aggregate="SUM"),
+                compare_lag=1,
+            )
+
+    def test_with_filters(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            filters=[
+                FilterConfig(column="region", op="=", value="US"),
+            ],
+        )
+        assert config.filters is not None
+        assert len(config.filters) == 1
+
+    def test_extra_fields_forbidden(self) -> None:
+        with pytest.raises(ValueError, match="Unknown field 'unknown_field'"):
+            BigNumberChartConfig(
+                chart_type="big_number",
+                metric=ColumnRef(name="revenue", aggregate="SUM"),
+                unknown_field="bad",
+            )
+
+    def test_with_time_grain(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            temporal_column="ds",
+            show_trendline=True,
+            time_grain="P1M",
+        )
+        assert config.time_grain == "P1M"
+
+    def test_with_custom_label(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM", label="Total Sales"),
+        )
+        assert config.metric.label == "Total Sales"
+
+
+class TestMapBigNumberConfig:
+    """Test map_big_number_config function."""
+
+    def test_basic_total(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+        )
+        form_data = map_big_number_config(config)
+        assert form_data["viz_type"] == "big_number_total"
+        assert form_data["metric"]["aggregate"] == "SUM"
+        assert form_data["metric"]["column"]["column_name"] == "revenue"
+
+    def test_with_trendline(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            temporal_column="order_date",
+            show_trendline=True,
+        )
+        form_data = map_big_number_config(config)
+        assert form_data["viz_type"] == "big_number"
+        assert form_data["show_trend_line"] is True
+        assert "x_axis" not in form_data
+        assert form_data["granularity_sqla"] == "order_date"
+        assert form_data["start_y_axis_at_zero"] is True
+
+    def test_with_time_grain(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            temporal_column="order_date",
+            show_trendline=True,
+            time_grain="P1M",
+        )
+        form_data = map_big_number_config(config)
+        assert form_data["time_grain_sqla"] == "P1M"
+
+    def test_with_subheader(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            subheader="Year to date",
+        )
+        form_data = map_big_number_config(config)
+        assert form_data["subheader"] == "Year to date"
+
+    def test_with_y_axis_format(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            y_axis_format="$,.2f",
+        )
+        form_data = map_big_number_config(config)
+        assert form_data["y_axis_format"] == "$,.2f"
+
+    def test_with_compare_lag(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            temporal_column="ds",
+            show_trendline=True,
+            compare_lag=7,
+        )
+        form_data = map_big_number_config(config)
+        assert form_data["compare_lag"] == 7
+
+    def test_with_filters(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            filters=[
+                FilterConfig(column="region", op="=", value="US"),
+            ],
+        )
+        form_data = map_big_number_config(config)
+        assert "adhoc_filters" in form_data
+        assert len(form_data["adhoc_filters"]) == 1
+        assert form_data["adhoc_filters"][0]["subject"] == "region"
+        assert form_data["adhoc_filters"][0]["operator"] == "=="
+        assert form_data["adhoc_filters"][0]["comparator"] == "US"
+
+    def test_no_trendline_fields_for_total(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+        )
+        form_data = map_big_number_config(config)
+        assert "x_axis" not in form_data
+        assert "granularity_sqla" not in form_data
+        assert "time_grain_sqla" not in form_data
+        assert "start_y_axis_at_zero" not in form_data
+
+
+class TestMapConfigToFormDataBigNumber:
+    """Test map_config_to_form_data dispatch for big number."""
+
+    def test_dispatches_big_number(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+        )
+        form_data = map_config_to_form_data(config)
+        assert form_data["viz_type"] == "big_number_total"
+
+    def test_dispatches_big_number_trendline(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            temporal_column="ds",
+            show_trendline=True,
+        )
+        form_data = map_config_to_form_data(config)
+        assert form_data["viz_type"] == "big_number"
+
+
+class TestGenerateChartNameBigNumber:
+    """Test generate_chart_name for big number configs."""
+
+    def test_basic_total_name(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+        )
+        name = generate_chart_name(config)
+        assert "Big Number" in name
+        assert "SUM(revenue)" in name
+
+    def test_with_trendline_name(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            temporal_column="ds",
+            show_trendline=True,
+        )
+        name = generate_chart_name(config)
+        assert "Big Number" in name
+        assert "trendline" in name
+
+    def test_with_custom_label(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(
+                name="revenue",
+                aggregate="SUM",
+                label="Total Sales",
+            ),
+        )
+        name = generate_chart_name(config)
+        assert "Total Sales" in name
+
+
+class TestResolveVizTypeBigNumber:
+    """Test _resolve_viz_type for big number configs."""
+
+    def test_big_number_total(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+        )
+        assert _resolve_viz_type(config) == "big_number_total"
+
+    def test_big_number_with_trendline(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            temporal_column="ds",
+            show_trendline=True,
+        )
+        assert _resolve_viz_type(config) == "big_number"
+
+
+class TestAnalyzeChartCapabilitiesBigNumber:
+    """Test analyze_chart_capabilities for big number configs."""
+
+    def test_big_number_total_capabilities(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+        )
+        result = analyze_chart_capabilities(None, config)
+        assert result.supports_export is True
+        assert result.supports_interaction is False
+        assert result.supports_drill_down is False
+
+    def test_big_number_trendline_capabilities(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            temporal_column="ds",
+            show_trendline=True,
+        )
+        result = analyze_chart_capabilities(None, config)
+        assert result.supports_export is True
+        assert result.supports_interaction is False
+        assert result.supports_drill_down is False
+
+
+class TestAnalyzeChartSemanticsBigNumber:
+    """Test analyze_chart_semantics for big number configs."""
+
+    def test_big_number_total_semantics(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+        )
+        result = analyze_chart_semantics(None, config)
+        assert result is not None
+        assert "metric" in result.primary_insight.lower()
+        assert result.data_story != ""
+        assert len(result.recommended_actions) > 0
+
+    def test_big_number_trendline_semantics(self) -> None:
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+            temporal_column="ds",
+            show_trendline=True,
+        )
+        result = analyze_chart_semantics(None, config)
+        assert result is not None
+        assert "trend" in result.primary_insight.lower()
+        assert result.data_story != ""
+        assert len(result.recommended_actions) > 0
+
+
+class TestSchemaValidatorBigNumber:
+    """Test SchemaValidator for big number chart type."""
+
+    def test_valid_big_number_request(self) -> None:
+        data = {
+            "dataset_id": 1,
+            "config": {
+                "chart_type": "big_number",
+                "metric": {"name": "revenue", "aggregate": "SUM"},
+            },
+        }
+        is_valid, request, error = SchemaValidator.validate_request(data)
+        assert is_valid is True
+        assert request is not None
+        assert error is None
+
+    def test_valid_big_number_with_trendline(self) -> None:
+        data = {
+            "dataset_id": 1,
+            "config": {
+                "chart_type": "big_number",
+                "metric": {"name": "revenue", "aggregate": "SUM"},
+                "temporal_column": "ds",
+                "show_trendline": True,
+            },
+        }
+        is_valid, request, error = SchemaValidator.validate_request(data)
+        assert is_valid is True
+        assert request is not None
+
+    def test_missing_metric(self) -> None:
+        data = {
+            "dataset_id": 1,
+            "config": {
+                "chart_type": "big_number",
+            },
+        }
+        is_valid, request, error = SchemaValidator.validate_request(data)
+        assert is_valid is False
+        assert error is not None
+        assert error.error_code == "MISSING_BIG_NUMBER_METRIC"
+
+    def test_invalid_metric_type(self) -> None:
+        data = {
+            "dataset_id": 1,
+            "config": {
+                "chart_type": "big_number",
+                "metric": "not_a_dict",
+            },
+        }
+        is_valid, request, error = SchemaValidator.validate_request(data)
+        assert is_valid is False
+        assert error is not None
+        assert error.error_code == "INVALID_BIG_NUMBER_METRIC_TYPE"
+
+    def test_missing_metric_aggregate(self) -> None:
+        data = {
+            "dataset_id": 1,
+            "config": {
+                "chart_type": "big_number",
+                "metric": {"name": "revenue"},
+            },
+        }
+        is_valid, request, error = SchemaValidator.validate_request(data)
+        assert is_valid is False
+        assert error is not None
+        assert error.error_code == "MISSING_BIG_NUMBER_AGGREGATE"
+
+    def test_trendline_without_temporal(self) -> None:
+        data = {
+            "dataset_id": 1,
+            "config": {
+                "chart_type": "big_number",
+                "metric": {"name": "revenue", "aggregate": "SUM"},
+                "show_trendline": True,
+            },
+        }
+        is_valid, request, error = SchemaValidator.validate_request(data)
+        assert is_valid is False
+        assert error is not None
+        assert error.error_code == "MISSING_TEMPORAL_COLUMN"
+
+    def test_big_number_accepted_in_chart_type_check(self) -> None:
+        """Verify big_number passes the chart_type validation."""
+        is_valid, error = SchemaValidator._pre_validate(
+            {
+                "dataset_id": 1,
+                "config": {
+                    "chart_type": "big_number",
+                    "metric": {"name": "revenue", "aggregate": "SUM"},
+                },
+            }
+        )
+        assert is_valid is True
+        assert error is None
+
+    def test_invalid_chart_type_still_rejected(self) -> None:
+        """Ensure unknown chart types are still rejected."""
+        is_valid, error = SchemaValidator._pre_validate(
+            {
+                "dataset_id": 1,
+                "config": {"chart_type": "nonexistent_chart"},
+            }
+        )
+        assert is_valid is False
+        assert error is not None
+        assert error.error_code == "INVALID_CHART_TYPE"
+
+    def test_big_number_with_subheader_and_format(self) -> None:
+        data = {
+            "dataset_id": 1,
+            "config": {
+                "chart_type": "big_number",
+                "metric": {"name": "revenue", "aggregate": "SUM"},
+                "subheader": "Year to date",
+                "y_axis_format": "$,.2f",
+            },
+        }
+        is_valid, request, error = SchemaValidator.validate_request(data)
+        assert is_valid is True
+        assert request is not None