@@ -13,3 +13,82 @@ A single scalar metric (count, sum, average, min, max).
```bash
npx shadcn@latest add @uipath/kpi-chart
```
+
+Installs both the prop-driven `KpiChart` view and the `KpiChartWithAdapter` variant.
+
+## View
+
+`KpiChart` renders a static label/value pair. Format the value yourself (this component just prints it).
+
+```tsx
+import { KpiChart } from "@/components/ui/kpi-chart";
+
+
+```
+
+### Props
+
+- `label: string` — small text shown above the value.
+- `value: string` — formatted display value.
+
+## With adapter
+
+`KpiChartWithAdapter` runs a single aggregate query and renders the result with `formatMetricValue` from charts-core (which respects the metric's expression and field type).
+
+The chart package does not bundle an adapter. Install one of the ready-made adapters (or implement the `DataAdapter` interface yourself):
+
+```bash
+npx shadcn@latest add @uipath/data-fabric-adapter
+# or
+npx shadcn@latest add @uipath/insights-adapter
+```
+
+```tsx
+import { KpiChartWithAdapter } from "@/components/ui/kpi-chart";
+import { dataFabricAdapter } from "@/lib/data-fabric-adapter";
+
+const adapter = dataFabricAdapter({
+ baseUrl: "/api/datafabric/.../api",
+ accessToken,
+ entityName: "Orders",
+});
+
+const dataModel = {
+ id: "total-revenue",
+ metrics: [
+ {
+ id: "revenue",
+ display: "Total revenue",
+ expression: {
+ type: "aggregate" as const,
+ aggregation: "SUM" as const,
+ argument: {
+ id: "Amount",
+ display: "Amount",
+ type: "currency" as const,
+ format: { currency: "USD" },
+ },
+ },
+ },
+ ],
+};
+
+const configuration = {
+ id: "revenue-kpi",
+ name: "Total revenue",
+ type: "kpi" as const,
+ metrics: ["revenue"],
+};
+
+
+```
+
+### Props
+
+- `configuration: KpiChartConfiguration` — one metric, no dimension.
+- `dataModel: KpiDataModel` — metrics only (no `dimensions` field).
+- `dataAdapter: DataAdapter` — implementation that runs the query.
diff --git a/apps/apollo-vertex/app/components/line-chart/page.mdx b/apps/apollo-vertex/app/components/line-chart/page.mdx
index 55cd229ce..d236650c8 100644
--- a/apps/apollo-vertex/app/components/line-chart/page.mdx
+++ b/apps/apollo-vertex/app/components/line-chart/page.mdx
@@ -13,3 +13,97 @@ A time-series line chart over a datetime dimension.
```bash
npx shadcn@latest add @uipath/line-chart
```
+
+Installs both the prop-driven `LineChart` view and the `LineChartWithAdapter` variant.
+
+## View
+
+`LineChart` plots an array of `{ x, y }` points. Use it when the series is already in memory.
+
+```tsx
+import { LineChart } from "@/components/ui/line-chart";
+
+const data = [
+ { x: "Jan", y: 120 },
+ { x: "Feb", y: 132 },
+ { x: "Mar", y: 101 },
+ // ...
+];
+
+const formatValue = (value: number) =>
+ new Intl.NumberFormat("en-US", { notation: "compact" }).format(value);
+
+
+```
+
+### Props
+
+- `data: Array<{ x: string; y: number }>` — points in order. `x` is rendered on the axis as-is.
+- `seriesLabel: string` — tooltip/legend label.
+- `formatValue: (value: number) => string` — formats Y values in the tooltip and axis.
+- `color?: string` — optional override; defaults to `var(--color-primary)`.
+
+## With adapter
+
+`LineChartWithAdapter` queries the configured `DataAdapter`, bins the response on the dimension's time axis, and renders the line. Wrapped in Suspense + error boundary.
+
+The chart package does not bundle an adapter. Install one of the ready-made adapters (or implement the `DataAdapter` interface yourself):
+
+```bash
+npx shadcn@latest add @uipath/data-fabric-adapter
+# or
+npx shadcn@latest add @uipath/insights-adapter
+```
+
+```tsx
+import { LineChartWithAdapter } from "@/components/ui/line-chart";
+import { dataFabricAdapter } from "@/lib/data-fabric-adapter";
+
+const adapter = dataFabricAdapter({
+ baseUrl: "/api/datafabric/.../api",
+ accessToken,
+ entityName: "Orders",
+});
+
+const dataModel = {
+ id: "orders-over-time",
+ dimensions: [
+ { id: "OrderDate", display: "Order date", type: "datetime" as const },
+ ],
+ metrics: [
+ {
+ id: "total",
+ display: "Total orders",
+ expression: {
+ type: "aggregate" as const,
+ aggregation: "COUNT" as const,
+ argument: { id: "Id", display: "Id", type: "numeric" as const },
+ },
+ },
+ ],
+};
+
+const configuration = {
+ id: "orders-line",
+ name: "Orders over time",
+ type: "line" as const,
+ dimensions: ["OrderDate"],
+ metrics: ["total"],
+};
+
+
+```
+
+### Props
+
+- `configuration: LineChartConfiguration` — single dimension + single metric (line charts always render one series).
+- `dataModel: ChartDataModel
` — dimension must be `datetime`.
+- `dataAdapter: DataAdapter` — implementation that runs the query.
diff --git a/apps/apollo-vertex/app/components/multi-line-chart/page.mdx b/apps/apollo-vertex/app/components/multi-line-chart/page.mdx
index 43b1df722..84dbd841f 100644
--- a/apps/apollo-vertex/app/components/multi-line-chart/page.mdx
+++ b/apps/apollo-vertex/app/components/multi-line-chart/page.mdx
@@ -2,7 +2,7 @@ import { MultiLineChartTemplate } from '@/templates/charts/MultiLineChartTemplat
# Multi-Line Chart
-Two metrics on a shared datetime axis with independent Y axes.
+Two metrics on a shared datetime axis with independent left and right Y axes.
@@ -13,3 +13,133 @@ Two metrics on a shared datetime axis with independent Y axes.
```bash
npx shadcn@latest add @uipath/multi-line-chart
```
+
+Installs both the prop-driven `MultiLineChart` view and the `MultiLineChartWithAdapter` variant.
+
+## View
+
+`MultiLineChart` plots up to two series sharing an X axis. Pass series with `axis: "left" | "right"` to drive independent Y axes (useful when units differ).
+
+```tsx
+import { MultiLineChart } from "@/components/ui/multi-line-chart";
+
+const data = [
+ { month: "Jan", orders: 120, revenue: 12000 },
+ { month: "Feb", orders: 132, revenue: 14500 },
+ // ...
+];
+
+const numberFormat = new Intl.NumberFormat("en-US");
+const currencyFormat = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+});
+
+
numberFormat.format(v),
+ totalText: numberFormat.format(252),
+ totalLabel: "Total orders",
+ },
+ {
+ id: "revenue",
+ label: "Revenue",
+ color: "var(--chart-2)",
+ axis: "right",
+ formatValue: (v) => currencyFormat.format(v),
+ totalText: currencyFormat.format(26500),
+ totalLabel: "Total revenue",
+ },
+ ]}
+/>
+```
+
+### Props
+
+- `data: Array>` — rows keyed by series id and `xKey`.
+- `xKey: string` — which property names the X axis (e.g., `"month"`).
+- `series: MultiLineChartSeries[]` — exactly two entries: one `axis: "left"`, one `axis: "right"`. Each carries label, color, formatter, and pre-computed `totalText`/`totalLabel` shown above the chart.
+
+## With adapter
+
+`MultiLineChartWithAdapter` issues one query per metric (parallel) against the shared dimension, bins datetime, and renders the dual-axis chart.
+
+The chart package does not bundle an adapter. Install one of the ready-made adapters (or implement the `DataAdapter` interface yourself):
+
+```bash
+npx shadcn@latest add @uipath/data-fabric-adapter
+# or
+npx shadcn@latest add @uipath/insights-adapter
+```
+
+```tsx
+import { MultiLineChartWithAdapter } from "@/components/ui/multi-line-chart";
+import { dataFabricAdapter } from "@/lib/data-fabric-adapter";
+
+const adapter = dataFabricAdapter({
+ baseUrl: "/api/datafabric/.../api",
+ accessToken,
+ entityName: "Orders",
+});
+
+const idField = { id: "Id", display: "Id", type: "numeric" as const };
+const amountField = {
+ id: "Amount",
+ display: "Amount",
+ type: "numeric" as const,
+};
+
+const dataModel = {
+ id: "orders-trends",
+ dimensions: [
+ { id: "OrderDate", display: "Order date", type: "datetime" as const },
+ ],
+ metrics: [
+ {
+ id: "order_count",
+ display: "Orders",
+ expression: {
+ type: "aggregate" as const,
+ aggregation: "COUNT" as const,
+ argument: idField,
+ },
+ },
+ {
+ id: "revenue",
+ display: "Revenue",
+ expression: {
+ type: "aggregate" as const,
+ aggregation: "SUM" as const,
+ argument: amountField,
+ },
+ },
+ ],
+};
+
+const configuration = {
+ id: "orders-multi-line",
+ name: "Orders + revenue over time",
+ type: "multi_line" as const,
+ dimensions: ["OrderDate"],
+ metrics: ["order_count", "revenue"],
+};
+
+
+```
+
+### Props
+
+- `configuration: MultiLineChartConfiguration` — one datetime dimension, exactly two metrics.
+- `dataModel: ChartDataModel` — dimension must be `datetime`.
+- `dataAdapter: DataAdapter` — implementation that runs the queries.
diff --git a/apps/apollo-vertex/app/components/table-chart/page.mdx b/apps/apollo-vertex/app/components/table-chart/page.mdx
index d33f0dddb..caa184244 100644
--- a/apps/apollo-vertex/app/components/table-chart/page.mdx
+++ b/apps/apollo-vertex/app/components/table-chart/page.mdx
@@ -2,7 +2,7 @@ import { TableChartTemplate } from '@/templates/charts/TableChartTemplate';
# Table Chart
-A sortable table of entity rows.
+A sortable table of entity rows. Use for record-level views — no aggregation, just listing.
@@ -13,3 +13,127 @@ A sortable table of entity rows.
```bash
npx shadcn@latest add @uipath/table-chart
```
+
+Installs both the prop-driven `TableChart` view and the `TableChartWithAdapter` variant.
+
+## View
+
+`TableChart` renders rows with column-level formatting and optional client-controlled sorting.
+
+```tsx
+import { TableChart } from "@/components/ui/table-chart";
+
+const rows = [
+ { department: "Marketing", headcount: 12, avgSalary: 58000, active: true },
+ { department: "Engineering", headcount: 28, avgSalary: 66000, active: false },
+ // ...
+];
+
+const numberFormat = new Intl.NumberFormat("en-US");
+
+
numberFormat.format(Number(v)),
+ },
+ {
+ id: "avgSalary",
+ label: "Avg salary",
+ align: "right",
+ format: (v) => `$${numberFormat.format(Number(v))}`,
+ },
+ {
+ id: "active",
+ label: "Active",
+ align: "left",
+ format: (v) => (v ? "Yes" : "No"),
+ },
+ ]}
+ rows={rows}
+/>
+```
+
+### Props
+
+- `columns: TableChartColumn[]` — id, label, alignment, and a per-column `format(value): string` function.
+- `rows: Array>` — each row keyed by column id.
+- `sort?: TableChartSort | null` — controlled sort state. Omit for uncontrolled sorting.
+- `onSortChange?: (sort: TableChartSort | null) => void` — fires when the user clicks a header.
+
+## With adapter
+
+`TableChartWithAdapter` fetches rows via the adapter, derives column formatters from the data model's field types (currency, percentage, boolean format, etc.), and threads sort state into the query.
+
+The chart package does not bundle an adapter. Install one of the ready-made adapters (or implement the `DataAdapter` interface yourself):
+
+```bash
+npx shadcn@latest add @uipath/data-fabric-adapter
+# or
+npx shadcn@latest add @uipath/insights-adapter
+```
+
+```tsx
+import { useState } from "react";
+import { TableChartWithAdapter } from "@/components/ui/table-chart";
+import type { TableChartState } from "@uipath/charts-core";
+import { dataFabricAdapter } from "@/lib/data-fabric-adapter";
+
+const adapter = dataFabricAdapter({
+ baseUrl: "/api/datafabric/.../api",
+ accessToken,
+ entityName: "Orders",
+});
+
+const dataModel = {
+ id: "orders-table",
+ fields: [
+ { id: "CustomerName", display: "Customer", type: "string" as const },
+ {
+ id: "Amount",
+ display: "Amount",
+ type: "currency" as const,
+ format: { currency: "USD" },
+ },
+ { id: "Status", display: "Status", type: "string" as const },
+ { id: "OrderDate", display: "Order date", type: "datetime" as const },
+ ],
+};
+
+const configuration = {
+ id: "orders-table",
+ name: "Orders",
+ type: "table" as const,
+ dimensions: ["CustomerName", "Amount", "Status", "OrderDate"],
+};
+
+function OrdersTable() {
+ const [state, setState] = useState({ sortBy: null });
+
+ return (
+
+ );
+}
+```
+
+### Props
+
+- `configuration: TableChartConfiguration` — `dimensions` IDs to include as columns.
+- `dataModel: TableDataModel` — flat list of `DataModelField`s. Field `type` drives formatting (currency uses `format.currency`, boolean uses `format.trueDisplay`/`falseDisplay`, etc.).
+- `dataAdapter: DataAdapter` — implementation that runs the query.
+- `state?: TableChartState` — controlled `{ sortBy }`. Sort changes refetch.
+- `onStateChange?: (next: TableChartState) => void` — fires when the user changes sort.
diff --git a/apps/apollo-vertex/lib/api-proxy.ts b/apps/apollo-vertex/lib/api-proxy.ts
new file mode 100644
index 000000000..4d7dc8a2f
--- /dev/null
+++ b/apps/apollo-vertex/lib/api-proxy.ts
@@ -0,0 +1,57 @@
+import { type NextRequest, NextResponse } from "next/server";
+
+const UPSTREAM_ORIGIN = "https://alpha.uipath.com";
+
+interface ProxyToUiPathOptions {
+ requiredServiceSegment: string;
+}
+
+function buildTargetUrl(path: string[], search: string) {
+ return `${UPSTREAM_ORIGIN}/${path.join("/")}${search}`;
+}
+
+export async function proxyToUiPath(
+ request: NextRequest,
+ path: string[],
+ { requiredServiceSegment }: ProxyToUiPathOptions,
+): Promise {
+ const authHeader = request.headers.get("authorization");
+ if (!authHeader) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (path[2] !== requiredServiceSegment) {
+ return NextResponse.json(
+ {
+ error: `Proxy only forwards to "${requiredServiceSegment}" — refused path "/${path.join("/")}".`,
+ },
+ { status: 403 },
+ );
+ }
+
+ const targetUrl = buildTargetUrl(path, request.nextUrl.search);
+ const method = request.method;
+ const hasBody = method !== "GET" && method !== "HEAD";
+
+ const headers: HeadersInit = { Authorization: authHeader };
+ if (hasBody) {
+ headers["Content-Type"] =
+ request.headers.get("content-type") ?? "application/json";
+ }
+
+ const response = await fetch(targetUrl, {
+ method,
+ headers,
+ body: hasBody ? await request.text() : null,
+ signal: request.signal,
+ });
+
+ const text = await response.text();
+ return new NextResponse(text, {
+ status: response.status,
+ headers: {
+ "Content-Type":
+ response.headers.get("content-type") ?? "application/json",
+ },
+ });
+}
diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json
index 7ef07edad..96f696874 100644
--- a/apps/apollo-vertex/registry.json
+++ b/apps/apollo-vertex/registry.json
@@ -347,6 +347,11 @@
"path": "types/luxon.d.ts",
"type": "registry:lib"
},
+ {
+ "path": "registry/charts-core/chart-data-mapper.ts",
+ "type": "registry:lib",
+ "target": "lib/charts-core/chart-data-mapper.ts"
+ },
{
"path": "registry/charts-core/chart-loading-boundary.tsx",
"type": "registry:lib",
@@ -367,6 +372,11 @@
"type": "registry:lib",
"target": "lib/charts-core/data-adapter.ts"
},
+ {
+ "path": "registry/charts-core/data-query-response-schema.ts",
+ "type": "registry:lib",
+ "target": "lib/charts-core/data-query-response-schema.ts"
+ },
{
"path": "registry/charts-core/error-boundary.tsx",
"type": "registry:lib",
@@ -428,9 +438,14 @@
"target": "lib/charts-core/models/configurations/table-chart-configuration.ts"
},
{
- "path": "registry/charts-core/models/aggregation.ts",
+ "path": "registry/charts-core/models/expression.ts",
"type": "registry:lib",
- "target": "lib/charts-core/models/aggregation.ts"
+ "target": "lib/charts-core/models/expression.ts"
+ },
+ {
+ "path": "registry/charts-core/models/field.ts",
+ "type": "registry:lib",
+ "target": "lib/charts-core/models/field.ts"
},
{
"path": "registry/charts-core/models/filter-values.ts",
@@ -581,6 +596,16 @@
"path": "registry/charts-core/util/format/percentage-format-options.ts",
"type": "registry:lib",
"target": "lib/charts-core/util/format/percentage-format-options.ts"
+ },
+ {
+ "path": "registry/charts-core/util/build-field.ts",
+ "type": "registry:lib",
+ "target": "lib/charts-core/util/build-field.ts"
+ },
+ {
+ "path": "registry/charts-core/util/get-metric-field-type.ts",
+ "type": "registry:lib",
+ "target": "lib/charts-core/util/get-metric-field-type.ts"
}
]
},
@@ -643,11 +668,6 @@
"type": "registry:lib",
"target": "lib/data-fabric-adapter/data-fabric-adapter.ts"
},
- {
- "path": "registry/data-fabric-adapter/schemas/data-query-response-schema.ts",
- "type": "registry:lib",
- "target": "lib/data-fabric-adapter/schemas/data-query-response-schema.ts"
- },
{
"path": "registry/data-fabric-adapter/schemas/query-schema.ts",
"type": "registry:lib",
@@ -658,11 +678,6 @@
"type": "registry:lib",
"target": "lib/data-fabric-adapter/utils/binning-utils.ts"
},
- {
- "path": "registry/data-fabric-adapter/utils/chart-data-mapper.ts",
- "type": "registry:lib",
- "target": "lib/data-fabric-adapter/utils/chart-data-mapper.ts"
- },
{
"path": "registry/data-fabric-adapter/utils/fetch-date-binned-data.ts",
"type": "registry:lib",
@@ -700,6 +715,122 @@
}
]
},
+ {
+ "name": "insights-adapter",
+ "type": "registry:lib",
+ "title": "Insights Adapter",
+ "description": "DataAdapter implementation that talks to the UiPath Insights standalone-query API. Pair with any chart from charts-core.",
+ "dependencies": [
+ "@tanstack/react-query@^5.90.0",
+ "@ts-rest/core@^3.53.0-rc.1",
+ "@types/luxon",
+ "luxon",
+ "zod"
+ ],
+ "registryDependencies": ["@uipath/charts-core"],
+ "files": [
+ {
+ "path": "registry/insights-adapter/adapter.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/adapter.ts"
+ },
+ {
+ "path": "registry/insights-adapter/chart-adapters/bar.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/chart-adapters/bar.ts"
+ },
+ {
+ "path": "registry/insights-adapter/chart-adapters/distribution.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/chart-adapters/distribution.ts"
+ },
+ {
+ "path": "registry/insights-adapter/chart-adapters/kpi.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/chart-adapters/kpi.ts"
+ },
+ {
+ "path": "registry/insights-adapter/chart-adapters/line.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/chart-adapters/line.ts"
+ },
+ {
+ "path": "registry/insights-adapter/chart-adapters/multi-line.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/chart-adapters/multi-line.ts"
+ },
+ {
+ "path": "registry/insights-adapter/chart-adapters/table.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/chart-adapters/table.ts"
+ },
+ {
+ "path": "registry/insights-adapter/contract.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/contract.ts"
+ },
+ {
+ "path": "registry/insights-adapter/insights-adapter.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/insights-adapter.ts"
+ },
+ {
+ "path": "registry/insights-adapter/schemas/aggregate-fragment-schema.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/schemas/aggregate-fragment-schema.ts"
+ },
+ {
+ "path": "registry/insights-adapter/schemas/filter-fragment-schema.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/schemas/filter-fragment-schema.ts"
+ },
+ {
+ "path": "registry/insights-adapter/schemas/filter-request-schema.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/schemas/filter-request-schema.ts"
+ },
+ {
+ "path": "registry/insights-adapter/schemas/query-schema.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/schemas/query-schema.ts"
+ },
+ {
+ "path": "registry/insights-adapter/utils/assert-configuration-supported.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/utils/assert-configuration-supported.ts"
+ },
+ {
+ "path": "registry/insights-adapter/utils/binning.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/utils/binning.ts"
+ },
+ {
+ "path": "registry/insights-adapter/utils/filter-request.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/utils/filter-request.ts"
+ },
+ {
+ "path": "registry/insights-adapter/utils/metric-aggregate.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/utils/metric-aggregate.ts"
+ },
+ {
+ "path": "registry/insights-adapter/utils/query-options.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/utils/query-options.ts"
+ },
+ {
+ "path": "registry/insights-adapter/utils/query.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/utils/query.ts"
+ },
+ {
+ "path": "registry/insights-adapter/utils/throw-error.ts",
+ "type": "registry:lib",
+ "target": "lib/insights-adapter/utils/throw-error.ts"
+ }
+ ]
+ },
{
"name": "line-chart",
"type": "registry:ui",
diff --git a/apps/apollo-vertex/registry/ai-chat/tools/data-fabric-table/table-data-model.ts b/apps/apollo-vertex/registry/ai-chat/tools/data-fabric-table/table-data-model.ts
index 48948be51..411ce4341 100644
--- a/apps/apollo-vertex/registry/ai-chat/tools/data-fabric-table/table-data-model.ts
+++ b/apps/apollo-vertex/registry/ai-chat/tools/data-fabric-table/table-data-model.ts
@@ -1,4 +1,5 @@
import { assert } from "@/lib/asserts/assert";
+import { buildField } from "@/lib/charts-core";
import {
type Entity,
type EntityField,
@@ -8,11 +9,9 @@ import {
export function buildTableDataModel(entity: Entity) {
return {
id: entity.id,
- fields: entity.fields.map((field) => ({
- id: field.name,
- display: field.name,
- type: mapFieldType(field.dataType),
- })),
+ fields: entity.fields.map((field) =>
+ buildField(field.name, mapFieldType(field.dataType)),
+ ),
};
}
@@ -29,11 +28,7 @@ export function buildMultiEntityDataModel(
field != null,
`buildMultiEntityDataModel: dimension "${d}" is not present in qualified fields. Caller must validate dimensions first.`,
);
- return {
- id: d,
- display: d,
- type: mapFieldType(field.dataType),
- };
+ return buildField(d, mapFieldType(field.dataType));
}),
};
}
diff --git a/apps/apollo-vertex/registry/ai-chat/tools/data-fabric/util/chart-helpers.ts b/apps/apollo-vertex/registry/ai-chat/tools/data-fabric/util/chart-helpers.ts
index acfe7ce24..2a002e278 100644
--- a/apps/apollo-vertex/registry/ai-chat/tools/data-fabric/util/chart-helpers.ts
+++ b/apps/apollo-vertex/registry/ai-chat/tools/data-fabric/util/chart-helpers.ts
@@ -1,10 +1,11 @@
import { z } from "zod";
import {
- Aggregation,
+ type AggregationKind,
+ buildField,
type ChartDataModel,
type DataModelField,
+ type DataModelFieldType,
type DataModelMetric,
- type DimensionType,
} from "@/lib/charts-core";
import {
type Entity,
@@ -21,52 +22,58 @@ import { fail, ok, type ResolverResult } from "./resolver-result";
// the server respects what we send. The `field` reference still needs to
// be qualified for the server to resolve the correct entity.
export function buildMetricEntry(metric: ResolvedMetric): DataModelMetric {
- const aliasField = metric.field.replaceAll(".", "_");
+ const aliasField = metric.argument.id.replaceAll(".", "_");
return {
- id: `${metric.aggregation.kind}_${aliasField}`,
+ id: `${metric.aggregation}_${aliasField}`,
display: metric.display,
- aggregation: metric.aggregation,
+ expression: {
+ type: "aggregate",
+ aggregation: metric.aggregation,
+ argument: metric.argument,
+ },
};
}
-interface BuildDataModelInput {
+interface BuildDataModelInput {
id: string;
dimension: string;
dimensionType: T;
metric: ResolvedMetric;
}
-export function buildDataModel({
+export function buildDataModel({
id,
dimension,
dimensionType,
metric,
-}: BuildDataModelInput): ChartDataModel {
+}: BuildDataModelInput): ChartDataModel<
+ Extract
+> {
return {
id,
- dimensions: [{ id: dimension, type: dimensionType }],
+ dimensions: [buildField(dimension, dimensionType)],
metrics: [buildMetricEntry(metric)],
};
}
-interface BuildMultiMetricDataModelInput {
+interface BuildMultiMetricDataModelInput {
id: string;
dimension: string;
dimensionType: T;
metrics: ResolvedMetric[];
}
-export function buildMultiMetricDataModel({
+export function buildMultiMetricDataModel({
id,
dimension,
dimensionType,
metrics,
}: BuildMultiMetricDataModelInput): ChartDataModel<
- DataModelField & { type: T }
+ Extract
> {
return {
id,
- dimensions: [{ id: dimension, type: dimensionType }],
+ dimensions: [buildField(dimension, dimensionType)],
metrics: metrics.map((m) => buildMetricEntry(m)),
};
}
@@ -97,68 +104,77 @@ export const metricSchema = z
export type MetricInput = z.infer;
-export function formatAggregation(aggregation: Aggregation): string {
- switch (aggregation.kind) {
- case "count":
+export function formatAggregation(aggregation: AggregationKind): string {
+ switch (aggregation) {
+ case "COUNT":
return "Count";
- case "sum":
+ case "SUM":
return "Sum";
- case "avg":
+ case "AVERAGE":
return "Avg";
- case "min":
+ case "MIN":
return "Min";
- case "max":
+ case "MAX":
return "Max";
+ case "ANY":
+ return "Any";
+ case "DISTINCT_COUNT":
+ return "Distinct count";
+ case "PERCENTAGE":
+ return "Percentage";
+ case "PERCENTILE":
+ return "Percentile";
+ case "MEDIAN":
+ return "Median";
}
}
-function metricInputToAggregation(
- input: MetricInput,
- field: string,
-): Aggregation {
+function metricInputToAggregateType(input: MetricInput): AggregationKind {
switch (input.aggregation) {
case "COUNT":
- return Aggregation.count(field);
+ return "COUNT";
case "SUM":
- return Aggregation.sum(field);
+ return "SUM";
case "AVG":
- return Aggregation.avg(field);
+ return "AVERAGE";
case "MIN":
- return Aggregation.min(field);
+ return "MIN";
case "MAX":
- return Aggregation.max(field);
+ return "MAX";
}
}
-export interface ResolvedDimension {
+export interface ResolvedDimension<
+ T extends DataModelFieldType = DataModelFieldType,
+> {
id: string;
type: T;
}
export interface ResolvedMetric {
- field: string;
- aggregation: Aggregation;
+ argument: DataModelField;
+ aggregation: AggregationKind;
display: string;
}
export function dedupeMetrics(metrics: ResolvedMetric[]): ResolvedMetric[] {
const seen = new Set();
return metrics.filter((m) => {
- const key = `${m.aggregation.kind}|${m.field}`;
+ const key = `${m.aggregation}|${m.argument.id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
-function isAllowedDimensionType(
- value: DimensionType,
+function isAllowedFieldType(
+ value: DataModelFieldType,
allowed: ReadonlyArray,
): value is T {
- return (allowed as readonly DimensionType[]).includes(value);
+ return (allowed as readonly DataModelFieldType[]).includes(value);
}
-export function resolveSingleDimension(
+export function resolveSingleDimension(
entity: Entity,
dimension: string,
allowed: ReadonlyArray,
@@ -172,7 +188,7 @@ export function resolveSingleDimension(
});
}
const type = mapFieldType(field.dataType);
- if (!isAllowedDimensionType(type, allowed)) {
+ if (!isAllowedFieldType(type, allowed)) {
return fail({
reason: "wrong_dimension_type_in_entity",
field: dimension,
@@ -184,7 +200,7 @@ export function resolveSingleDimension(
return ok({ id: field.name, type });
}
-export function resolveMultiDimension(
+export function resolveMultiDimension(
primaryEntity: string,
dimension: string,
qualifiedFields: Map,
@@ -201,7 +217,7 @@ export function resolveMultiDimension(
});
}
const type = mapFieldType(field.dataType);
- if (!isAllowedDimensionType(type, allowed)) {
+ if (!isAllowedFieldType(type, allowed)) {
return fail({
reason: "wrong_dimension_type_in_joined",
field: qualified,
@@ -221,16 +237,18 @@ export function resolveSingleMetric(
metric?.field && entity.fields.some((f) => f.name === metric.field)
? metric.field
: null;
- const field = userField ?? pickCountField(entity);
- if (!field) {
+ const fieldName = userField ?? pickCountField(entity);
+ if (!fieldName) {
return fail({
reason: "no_countable_in_entity",
entity: entity.name,
});
}
+ const entityField = entity.fields.find((f) => f.name === fieldName);
+ const type = entityField ? mapFieldType(entityField.dataType) : "string";
return ok({
- field,
- aggregation: Aggregation.count(field),
+ argument: buildField(fieldName, type),
+ aggregation: "COUNT",
display: userField ? `Count of ${userField}` : "Count",
});
}
@@ -258,9 +276,9 @@ export function resolveSingleMetric(
aggregation: metric.aggregation,
});
}
- const aggregation = metricInputToAggregation(metric, field.name);
+ const aggregation = metricInputToAggregateType(metric);
return ok({
- field: field.name,
+ argument: buildField(field.name, "numeric"),
aggregation,
display: `${formatAggregation(aggregation)} of ${field.name}`,
});
@@ -287,17 +305,19 @@ export function resolveMultiMetric(
: `${primaryEntity}.${metric.field}`;
if (qualifiedFields.has(qualified)) userField = qualified;
}
- const field =
+ const fieldName =
userField ?? pickCountFieldQualified(primaryEntity, qualifiedFields);
- if (!field) {
+ if (!fieldName) {
return fail({
reason: "no_countable_in_joined",
primary: primaryEntity,
});
}
+ const entityField = qualifiedFields.get(fieldName);
+ const type = entityField ? mapFieldType(entityField.dataType) : "string";
return ok({
- field,
- aggregation: Aggregation.count(field),
+ argument: buildField(fieldName, type),
+ aggregation: "COUNT",
display: userField
? `Count of ${unqualifiedDisplay(userField)}`
: "Count",
@@ -328,9 +348,9 @@ export function resolveMultiMetric(
aggregation: metric.aggregation,
});
}
- const aggregation = metricInputToAggregation(metric, qualified);
+ const aggregation = metricInputToAggregateType(metric);
return ok({
- field: qualified,
+ argument: buildField(qualified, "numeric"),
aggregation,
display: `${formatAggregation(aggregation)} of ${unqualifiedDisplay(qualified)}`,
});
diff --git a/apps/apollo-vertex/registry/bar-chart/bar-chart-with-adapter.tsx b/apps/apollo-vertex/registry/bar-chart/bar-chart-with-adapter.tsx
index 7c0396c5a..f2ad02bab 100644
--- a/apps/apollo-vertex/registry/bar-chart/bar-chart-with-adapter.tsx
+++ b/apps/apollo-vertex/registry/bar-chart/bar-chart-with-adapter.tsx
@@ -95,7 +95,7 @@ function BarChartResolver({
for (const m of metrics) {
const v = assertNumberOrNull(row[m.id], "Metric value") ?? 0;
values[m.id] = v;
- formattedValues[m.id] = formatMetricValue(language, v, m.aggregation);
+ formattedValues[m.id] = formatMetricValue(language, v, m.expression);
const total = totalsByMetricId[m.id] ?? 0;
formattedPercents[m.id] =
total === 0 ? "" : formatPercentage(language, v / total);
diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/utils/chart-data-mapper.ts b/apps/apollo-vertex/registry/charts-core/chart-data-mapper.ts
similarity index 57%
rename from apps/apollo-vertex/registry/data-fabric-adapter/utils/chart-data-mapper.ts
rename to apps/apollo-vertex/registry/charts-core/chart-data-mapper.ts
index 1c7c49769..59c3e5b8d 100644
--- a/apps/apollo-vertex/registry/data-fabric-adapter/utils/chart-data-mapper.ts
+++ b/apps/apollo-vertex/registry/charts-core/chart-data-mapper.ts
@@ -1,9 +1,6 @@
import { assertDefined } from "@/lib/asserts/assert-defined";
-import type { PrimitiveValue } from "@/lib/charts-core";
-import {
- type DataQueryResponse,
- PrimitiveValueSchema,
-} from "../schemas/data-query-response-schema";
+import type { DataQueryResponse } from "./data-query-response-schema";
+import type { PrimitiveValue } from "./models/primitive-value";
interface ChartDataMappingOptions {
dimensions: string[];
@@ -30,29 +27,29 @@ export function mapResponseToChartData({
.values;
});
- const metricValues = metrics?.map((metric) => {
+ const metricValues = metrics.map((metric) => {
return assertDefined(data[metric], `Metric data: ${metric}`).values;
});
return Array.from({ length: rowCount }, (_, rowIdx) => {
const entries: Array = [
...dimensions.map(
- (dimension, dimensionIdx): readonly [string, PrimitiveValue] => {
- const value = PrimitiveValueSchema.parse(
- assertDefined(
- dimensionValues[dimensionIdx],
- `Dimension ${dimensionIdx}`,
- )[rowIdx],
- );
- return [dimension, value];
- },
+ (dimension, dimensionIdx): readonly [string, PrimitiveValue] => [
+ dimension,
+ // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) `DataFabricQueryResponse` was validated upstream
+ assertDefined(
+ dimensionValues[dimensionIdx],
+ `Dimension ${dimensionIdx}`,
+ )[rowIdx] as PrimitiveValue,
+ ],
),
- ...metrics.map((metric, metricIdx): readonly [string, PrimitiveValue] => {
- const value = PrimitiveValueSchema.parse(
- assertDefined(metricValues[metricIdx], `Metric ${metricIdx}`)[rowIdx],
- );
- return [metric, value];
- }),
+ ...metrics.map((metric, metricIdx): readonly [string, PrimitiveValue] => [
+ metric,
+ // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) `DataFabricQueryResponse` was validated upstream
+ assertDefined(metricValues[metricIdx], `Metric ${metricIdx}`)[
+ rowIdx
+ ] as PrimitiveValue,
+ ]),
];
return Object.fromEntries(entries);
diff --git a/apps/apollo-vertex/registry/charts-core/chart-models.ts b/apps/apollo-vertex/registry/charts-core/chart-models.ts
index d0225b62b..135c6af62 100644
--- a/apps/apollo-vertex/registry/charts-core/chart-models.ts
+++ b/apps/apollo-vertex/registry/charts-core/chart-models.ts
@@ -1,18 +1,27 @@
import type { Interval } from "luxon";
-import type { Aggregation } from "./models/aggregation";
+import type { MetricExpression } from "./models/expression";
+import type {
+ DatetimeModelField,
+ DataModelField,
+ DataModelFieldType,
+ NumericOrDatetimeModelField,
+ StringModelField,
+} from "./models/field";
import type { PrimitiveValue } from "./models/primitive-value";
-export type DimensionType = "numeric" | "string" | "boolean" | "datetime";
-
-export interface DataModelField {
- id: string;
- type: DimensionType;
-}
+export type {
+ DataModelField,
+ DataModelFieldType,
+ DatetimeModelField,
+ NumericOrDatetimeModelField,
+ StringModelField,
+};
+export type DimensionType = DataModelFieldType;
export interface DataModelMetric {
id: string;
display: string;
- aggregation: Aggregation;
+ expression: MetricExpression;
}
export interface ChartDataModel<
@@ -28,12 +37,6 @@ export interface KpiDataModel {
metrics: DataModelMetric[];
}
-export type DatetimeModelField = DataModelField & { type: "datetime" };
-export type NumericOrDatetimeModelField = DataModelField & {
- type: "numeric" | "datetime";
-};
-export type StringModelField = DataModelField & { type: "string" };
-
export interface KpiChartData<_TMetaData = unknown> {
data: number | number[];
labels?: string[];
diff --git a/apps/apollo-vertex/registry/charts-core/charts-core.ts b/apps/apollo-vertex/registry/charts-core/charts-core.ts
index 033f063c6..afeb6fcdf 100644
--- a/apps/apollo-vertex/registry/charts-core/charts-core.ts
+++ b/apps/apollo-vertex/registry/charts-core/charts-core.ts
@@ -1,23 +1,29 @@
+export { mapResponseToChartData } from "./chart-data-mapper";
export { ChartLoadingBoundary } from "./chart-loading-boundary";
export type {
BarChartData,
ChartDataModel,
- DataModelField,
- DataModelMetric,
DatetimeModelField,
DimensionType,
DistributionChartData,
+ DataModelField,
+ DataModelFieldType,
KpiChartData,
KpiDataModel,
LineChartData,
+ DataModelMetric,
MultiLineChartData,
NumericOrDatetimeModelField,
StringModelField,
TableChartData,
} from "./chart-models";
export type { DataAdapter } from "./data-adapter";
+export {
+ type DataQueryResponse,
+ DataQueryResponseSchema,
+ PrimitiveValueSchema,
+} from "./data-query-response-schema";
export { mapConfigFilterToFilterValues } from "./map-filter-config";
-export { Aggregation, type AggregationKind } from "./models/aggregation";
export {
type BarChartConfiguration,
BarChartConfigurationSchema,
@@ -49,6 +55,11 @@ export {
type TableChartConfiguration,
TableChartConfigurationSchema,
} from "./models/configurations/table-chart-configuration";
+export type {
+ DataModelAggregate,
+ AggregationKind,
+ MetricExpression,
+} from "./models/expression";
export type { ListFilter } from "./models/filter";
export type { FilterValues } from "./models/filter-values";
export {
@@ -64,7 +75,9 @@ export { niceDurationNumbers } from "./util/binning/nice-duration-numbers";
export { niceNumbers } from "./util/binning/nice-numbers";
export { dataTypeAlignment } from "./util/data-type-alignment";
export { binLabel } from "./util/format/bin-label";
+export { buildField } from "./util/build-field";
export { format } from "./util/format/format";
export { formatMetricValue } from "./util/format/format-metric-value";
export { formatPercentage } from "./util/format/format-percentage";
export { getChartRange } from "./util/format/get-chart-range";
+export { getMetricFieldType } from "./util/get-metric-field-type";
diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/schemas/data-query-response-schema.ts b/apps/apollo-vertex/registry/charts-core/data-query-response-schema.ts
similarity index 100%
rename from apps/apollo-vertex/registry/data-fabric-adapter/schemas/data-query-response-schema.ts
rename to apps/apollo-vertex/registry/charts-core/data-query-response-schema.ts
diff --git a/apps/apollo-vertex/registry/charts-core/models/aggregation.ts b/apps/apollo-vertex/registry/charts-core/models/aggregation.ts
deleted file mode 100644
index 34658465a..000000000
--- a/apps/apollo-vertex/registry/charts-core/models/aggregation.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-export type Aggregation =
- | { kind: "count"; field?: string }
- | { kind: "sum"; field: string }
- | { kind: "avg"; field: string }
- | { kind: "min"; field: string }
- | { kind: "max"; field: string };
-
-export type AggregationKind = Aggregation["kind"];
-
-export const Aggregation = {
- count: (field?: string): Aggregation => ({ kind: "count", field }),
- sum: (field: string): Aggregation => ({ kind: "sum", field }),
- avg: (field: string): Aggregation => ({ kind: "avg", field }),
- min: (field: string): Aggregation => ({ kind: "min", field }),
- max: (field: string): Aggregation => ({ kind: "max", field }),
-} as const;
diff --git a/apps/apollo-vertex/registry/charts-core/models/configurations/base-chart-configuration.ts b/apps/apollo-vertex/registry/charts-core/models/configurations/base-chart-configuration.ts
index 731022fde..a09e578e0 100644
--- a/apps/apollo-vertex/registry/charts-core/models/configurations/base-chart-configuration.ts
+++ b/apps/apollo-vertex/registry/charts-core/models/configurations/base-chart-configuration.ts
@@ -10,4 +10,5 @@ export const BaseChartConfigurationSchema = z.object({
filters: z.array(FilterValuesSchema).optional(),
joins: z.array(JoinConfigSchema).optional(),
from: FromConfigSchema.optional(),
+ filterTableId: z.string().optional(),
});
diff --git a/apps/apollo-vertex/registry/charts-core/models/expression.ts b/apps/apollo-vertex/registry/charts-core/models/expression.ts
new file mode 100644
index 000000000..90ba6c220
--- /dev/null
+++ b/apps/apollo-vertex/registry/charts-core/models/expression.ts
@@ -0,0 +1,24 @@
+import type { DataModelField } from "./field";
+import type { FilterValues } from "./filter-values";
+
+export type AggregationKind =
+ | "ANY"
+ | "DISTINCT_COUNT"
+ | "COUNT"
+ | "AVERAGE"
+ | "MIN"
+ | "MAX"
+ | "PERCENTAGE"
+ | "SUM"
+ | "PERCENTILE"
+ | "MEDIAN";
+
+export interface DataModelAggregate {
+ id?: string;
+ type: "aggregate";
+ aggregation: AggregationKind;
+ argument: DataModelField;
+ filters?: FilterValues[];
+}
+
+export type MetricExpression = DataModelAggregate;
diff --git a/apps/apollo-vertex/registry/charts-core/models/field.ts b/apps/apollo-vertex/registry/charts-core/models/field.ts
new file mode 100644
index 000000000..618a2e396
--- /dev/null
+++ b/apps/apollo-vertex/registry/charts-core/models/field.ts
@@ -0,0 +1,52 @@
+interface BaseField {
+ id: string;
+ display: string;
+}
+
+interface NumericField extends BaseField {
+ type: "numeric";
+}
+
+interface StringField extends BaseField {
+ type: "string";
+}
+
+interface DateTimeField extends BaseField {
+ type: "datetime";
+}
+
+interface PercentageField extends BaseField {
+ type: "percentage";
+}
+
+interface DurationField extends BaseField {
+ type: "duration";
+}
+
+interface CurrencyField extends BaseField {
+ type: "currency";
+ format?: { currency: string };
+}
+
+interface BooleanField extends BaseField {
+ type: "boolean";
+ format?: { trueDisplay: string; falseDisplay: string };
+}
+
+export type DataModelField =
+ | NumericField
+ | StringField
+ | DateTimeField
+ | PercentageField
+ | DurationField
+ | CurrencyField
+ | BooleanField;
+
+export type DataModelFieldType = DataModelField["type"];
+
+export type DatetimeModelField = Extract;
+export type StringModelField = Extract;
+export type NumericOrDatetimeModelField = Extract<
+ DataModelField,
+ { type: "numeric" | "datetime" }
+>;
diff --git a/apps/apollo-vertex/registry/charts-core/table-data-model.ts b/apps/apollo-vertex/registry/charts-core/table-data-model.ts
index c38ca56fc..273dad673 100644
--- a/apps/apollo-vertex/registry/charts-core/table-data-model.ts
+++ b/apps/apollo-vertex/registry/charts-core/table-data-model.ts
@@ -1,12 +1,8 @@
-import type { DimensionType } from "./chart-models";
+import type { DataModelField } from "./models/field";
-export interface TableDataModelField {
- id: string;
- display: string;
- type: DimensionType;
-}
+export type TableDataModelField = DataModelField;
export interface TableDataModel {
id: string;
- fields: Array;
+ fields: Array;
}
diff --git a/apps/apollo-vertex/registry/charts-core/util/build-field.ts b/apps/apollo-vertex/registry/charts-core/util/build-field.ts
new file mode 100644
index 000000000..e2e488190
--- /dev/null
+++ b/apps/apollo-vertex/registry/charts-core/util/build-field.ts
@@ -0,0 +1,12 @@
+import type { DataModelField, DataModelFieldType } from "../models/field";
+
+export function buildField(
+ id: string,
+ type: T,
+): Extract;
+export function buildField(
+ id: string,
+ type: DataModelFieldType,
+): DataModelField {
+ return { id, display: id, type };
+}
diff --git a/apps/apollo-vertex/registry/charts-core/util/data-type-alignment.ts b/apps/apollo-vertex/registry/charts-core/util/data-type-alignment.ts
index 19a0d6d70..eaf465a6d 100644
--- a/apps/apollo-vertex/registry/charts-core/util/data-type-alignment.ts
+++ b/apps/apollo-vertex/registry/charts-core/util/data-type-alignment.ts
@@ -1,11 +1,14 @@
-import type { DimensionType } from "../chart-models";
+import type { DataModelFieldType } from "../chart-models";
export function dataTypeAlignment(field: {
- type: DimensionType;
+ type: DataModelFieldType;
}): "left" | "right" {
switch (field.type) {
case "datetime":
case "numeric":
+ case "currency":
+ case "percentage":
+ case "duration":
return "right";
case "boolean":
case "string":
diff --git a/apps/apollo-vertex/registry/charts-core/util/format/format-metric-value.ts b/apps/apollo-vertex/registry/charts-core/util/format/format-metric-value.ts
index 78ed49cc4..43d0bfceb 100644
--- a/apps/apollo-vertex/registry/charts-core/util/format/format-metric-value.ts
+++ b/apps/apollo-vertex/registry/charts-core/util/format/format-metric-value.ts
@@ -1,19 +1,34 @@
-import type { Aggregation } from "../../models/aggregation";
+import type { MetricExpression } from "../../models/expression";
+import { getMetricFieldType } from "../get-metric-field-type";
import type { BaseFormatOptions } from "./base-format-options";
import { format } from "./format";
export function formatMetricValue(
locale: Intl.LocalesArgument,
value: unknown,
- aggregation: Aggregation,
+ expression: MetricExpression,
options?: BaseFormatOptions,
): string {
- switch (aggregation.kind) {
- case "count":
- case "sum":
- case "avg":
- case "min":
- case "max":
- return format(locale, value, "numeric", options);
+ const fieldType = getMetricFieldType(expression);
+
+ if (fieldType === "currency" && expression.argument.type === "currency") {
+ const fmt = expression.argument.format;
+ return format(locale, value, "currency", {
+ ...options,
+ ...(fmt && { currency: fmt.currency }),
+ });
+ }
+
+ if (fieldType === "boolean" && expression.argument.type === "boolean") {
+ const fmt = expression.argument.format;
+ return format(locale, value, "boolean", {
+ ...options,
+ ...(fmt && {
+ trueLabel: fmt.trueDisplay,
+ falseLabel: fmt.falseDisplay,
+ }),
+ });
}
+
+ return format(locale, value, fieldType, options);
}
diff --git a/apps/apollo-vertex/registry/charts-core/util/format/format.ts b/apps/apollo-vertex/registry/charts-core/util/format/format.ts
index 5a0b039c4..50741913e 100644
--- a/apps/apollo-vertex/registry/charts-core/util/format/format.ts
+++ b/apps/apollo-vertex/registry/charts-core/util/format/format.ts
@@ -13,7 +13,7 @@ import { formatDuration } from "./format-duration";
import { formatNumber } from "./format-number";
import { formatPercentage } from "./format-percentage";
-type FieldType =
+type DataModelFieldType =
| "string"
| "boolean"
| "id"
@@ -39,7 +39,7 @@ type TypeToFormat = {
// Generic-narrowing pattern: each case narrows fieldType to a specific
// literal, but TS can't narrow `options: TypeToFormat[TFieldType]` along
// with it, so the `as` casts are mechanical and safe.
-export const format = (
+export const format = (
locale: Intl.LocalesArgument,
value: unknown,
fieldType: TFieldType,
diff --git a/apps/apollo-vertex/registry/charts-core/util/get-metric-field-type.ts b/apps/apollo-vertex/registry/charts-core/util/get-metric-field-type.ts
new file mode 100644
index 000000000..1956d7e71
--- /dev/null
+++ b/apps/apollo-vertex/registry/charts-core/util/get-metric-field-type.ts
@@ -0,0 +1,22 @@
+import type { MetricExpression } from "../models/expression";
+import type { DataModelFieldType } from "../models/field";
+
+export function getMetricFieldType(
+ expression: MetricExpression,
+): DataModelFieldType {
+ switch (expression.aggregation) {
+ case "COUNT":
+ case "DISTINCT_COUNT":
+ return "numeric";
+ case "PERCENTAGE":
+ return "percentage";
+ case "ANY":
+ case "AVERAGE":
+ case "MAX":
+ case "MEDIAN":
+ case "MIN":
+ case "PERCENTILE":
+ case "SUM":
+ return expression.argument.type;
+ }
+}
diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/adapter.ts b/apps/apollo-vertex/registry/data-fabric-adapter/adapter.ts
index e16313fc9..bc57bf113 100644
--- a/apps/apollo-vertex/registry/data-fabric-adapter/adapter.ts
+++ b/apps/apollo-vertex/registry/data-fabric-adapter/adapter.ts
@@ -1,7 +1,11 @@
import { queryOptions } from "@tanstack/react-query";
import { initClient } from "@ts-rest/core";
import { z } from "zod";
-import type { DataAdapter, ListFilter } from "@/lib/charts-core";
+import {
+ type DataAdapter,
+ type ListFilter,
+ PrimitiveValueSchema,
+} from "@/lib/charts-core";
import { dataFabricBarChartAdapter } from "./chart-adapters/bar";
import { dataFabricDistributionChartAdapter } from "./chart-adapters/distribution";
import { dataFabricKpiChartAdapter } from "./chart-adapters/kpi";
@@ -9,7 +13,6 @@ import { dataFabricLineChartAdapter } from "./chart-adapters/line";
import { dataFabricMultiLineChartAdapter } from "./chart-adapters/multi-line";
import { dataFabricTableChartAdapter } from "./chart-adapters/table";
import { dataFabricContract } from "./contract";
-import { PrimitiveValueSchema } from "./schemas/data-query-response-schema";
import { dataFabricQuery } from "./utils/query";
interface DataFabricAdapterProps {
diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/bar.ts b/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/bar.ts
index 1e1ec1459..9bfa31d5b 100644
--- a/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/bar.ts
+++ b/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/bar.ts
@@ -3,9 +3,9 @@ import { assertDefined } from "@/lib/asserts/assert-defined";
import {
type DataAdapter,
mapConfigFilterToFilterValues,
+ mapResponseToChartData,
} from "@/lib/charts-core";
import type { DataFabricQueryRequest } from "../schemas/query-schema";
-import { mapResponseToChartData } from "../utils/chart-data-mapper";
import { mapMetricToDataFabricAggregate } from "../utils/metric-aggregate";
import { type DataFabricClient, dataFabricQuery } from "../utils/query";
import { createDataFabricChartQueryOptions } from "../utils/query-options";
diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/table.ts b/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/table.ts
index 3c96c09c1..90ba4c827 100644
--- a/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/table.ts
+++ b/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/table.ts
@@ -1,7 +1,9 @@
import { queryOptions } from "@tanstack/react-query";
-import type { DataAdapter } from "@/lib/charts-core";
-import { mapConfigFilterToFilterValues } from "@/lib/charts-core";
-import { mapResponseToChartData } from "../utils/chart-data-mapper";
+import {
+ type DataAdapter,
+ mapConfigFilterToFilterValues,
+ mapResponseToChartData,
+} from "@/lib/charts-core";
import { type DataFabricClient, dataFabricQuery } from "../utils/query";
import { createDataFabricChartQueryOptions } from "../utils/query-options";
import { mapDataFabricResponseToChartData } from "../utils/response-data-mapper";
diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/schemas/query-schema.ts b/apps/apollo-vertex/registry/data-fabric-adapter/schemas/query-schema.ts
index 94e753293..baa731d25 100644
--- a/apps/apollo-vertex/registry/data-fabric-adapter/schemas/query-schema.ts
+++ b/apps/apollo-vertex/registry/data-fabric-adapter/schemas/query-schema.ts
@@ -1,5 +1,9 @@
import { z } from "zod";
-import { FromConfigSchema, JoinConfigSchema } from "@/lib/charts-core";
+import {
+ FromConfigSchema,
+ JoinConfigSchema,
+ PrimitiveValueSchema,
+} from "@/lib/charts-core";
const DataFabricSortOptionSchema = z.object({
fieldName: z.string(),
@@ -70,7 +74,7 @@ export const DataFabricQueryRequestSchema = z.object({
export const DataFabricQueryResponseSchema = z.object({
totalRecordCount: z.number(),
- value: z.array(z.record(z.string(), z.unknown())),
+ value: z.array(z.record(z.string(), PrimitiveValueSchema)),
});
export type DataFabricQueryRequest = z.infer<
diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/utils/fetch-date-binned-data.ts b/apps/apollo-vertex/registry/data-fabric-adapter/utils/fetch-date-binned-data.ts
index 49dd91c56..4389a769b 100644
--- a/apps/apollo-vertex/registry/data-fabric-adapter/utils/fetch-date-binned-data.ts
+++ b/apps/apollo-vertex/registry/data-fabric-adapter/utils/fetch-date-binned-data.ts
@@ -16,13 +16,13 @@ import { mapFilterValuesToDataFabricFilterGroup } from "./filter-group";
import type { DataFabricAggregate } from "./metric-aggregate";
import { type DataFabricClient, dataFabricQuery } from "./query";
-type Aggregate = DataFabricAggregate;
+type DataModelAggregate = DataFabricAggregate;
interface FetchDateBinnedDataOptions {
client: DataFabricClient;
entityName: string;
dimensionId: string;
- aggregates: Aggregate[];
+ aggregates: DataModelAggregate[];
filters: FilterValues[];
joins?: JoinConfig[];
from?: FromConfig;
diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/utils/metric-aggregate.ts b/apps/apollo-vertex/registry/data-fabric-adapter/utils/metric-aggregate.ts
index d932a1666..3fa062f44 100644
--- a/apps/apollo-vertex/registry/data-fabric-adapter/utils/metric-aggregate.ts
+++ b/apps/apollo-vertex/registry/data-fabric-adapter/utils/metric-aggregate.ts
@@ -11,21 +11,32 @@ export interface DataFabricAggregate {
export function mapMetricToDataFabricAggregate(
metric: DataModelMetric,
): DataFabricAggregate {
- const a = metric.aggregation;
- switch (a.kind) {
- case "count":
- return {
- function: "COUNT",
- field: a.field ?? metric.id,
- alias: metric.id,
- };
- case "sum":
- return { function: "SUM", field: a.field, alias: metric.id };
- case "avg":
- return { function: "AVG", field: a.field, alias: metric.id };
- case "min":
- return { function: "MIN", field: a.field, alias: metric.id };
- case "max":
- return { function: "MAX", field: a.field, alias: metric.id };
+ const expr = metric.expression;
+ if (expr.filters && expr.filters.length > 0) {
+ throw new Error(
+ `Data Fabric does not support per-aggregate filters (metric "${metric.id}").`,
+ );
+ }
+
+ const field = expr.argument.id;
+ switch (expr.aggregation) {
+ case "COUNT":
+ return { function: "COUNT", field, alias: metric.id };
+ case "SUM":
+ return { function: "SUM", field, alias: metric.id };
+ case "AVERAGE":
+ return { function: "AVG", field, alias: metric.id };
+ case "MIN":
+ return { function: "MIN", field, alias: metric.id };
+ case "MAX":
+ return { function: "MAX", field, alias: metric.id };
+ case "ANY":
+ case "DISTINCT_COUNT":
+ case "MEDIAN":
+ case "PERCENTAGE":
+ case "PERCENTILE":
+ throw new Error(
+ `Data Fabric does not support aggregation "${expr.aggregation}" (metric "${metric.id}").`,
+ );
}
}
diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/utils/response-data-mapper.ts b/apps/apollo-vertex/registry/data-fabric-adapter/utils/response-data-mapper.ts
index 2fd7c05e9..8a226a058 100644
--- a/apps/apollo-vertex/registry/data-fabric-adapter/utils/response-data-mapper.ts
+++ b/apps/apollo-vertex/registry/data-fabric-adapter/utils/response-data-mapper.ts
@@ -1,7 +1,4 @@
-import {
- type DataQueryResponse,
- PrimitiveValueSchema,
-} from "../schemas/data-query-response-schema";
+import type { DataQueryResponse } from "@/lib/charts-core";
import type { DataFabricQueryResponse } from "../schemas/query-schema";
export function mapDataFabricResponseToChartData(
@@ -19,10 +16,7 @@ export function mapDataFabricResponseToChartData(
for (const key of columnKeys) {
result[key] = {
- values: rows.map((row) => {
- const value = row[key];
- return value == null ? null : PrimitiveValueSchema.parse(value);
- }),
+ values: rows.map((row) => row[key] ?? null),
stackValues: null,
ungrouped: null,
};
diff --git a/apps/apollo-vertex/registry/distribution-chart/distribution-chart-with-adapter.tsx b/apps/apollo-vertex/registry/distribution-chart/distribution-chart-with-adapter.tsx
index a03197475..24a3dd8d5 100644
--- a/apps/apollo-vertex/registry/distribution-chart/distribution-chart-with-adapter.tsx
+++ b/apps/apollo-vertex/registry/distribution-chart/distribution-chart-with-adapter.tsx
@@ -98,7 +98,7 @@ function DistributionChartResolver({
data={data}
seriesLabel={metric.display}
formatValue={(value) =>
- formatMetricValue(language, value, metric.aggregation)
+ formatMetricValue(language, value, metric.expression)
}
/>
diff --git a/apps/apollo-vertex/registry/insights-adapter/adapter.ts b/apps/apollo-vertex/registry/insights-adapter/adapter.ts
new file mode 100644
index 000000000..7eebc519f
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/adapter.ts
@@ -0,0 +1,68 @@
+import { queryOptions } from "@tanstack/react-query";
+import { initClient } from "@ts-rest/core";
+import { z } from "zod";
+import {
+ type DataAdapter,
+ type ListFilter,
+ PrimitiveValueSchema,
+} from "@/lib/charts-core";
+import { insightsBarChartAdapter } from "./chart-adapters/bar";
+import { insightsDistributionChartAdapter } from "./chart-adapters/distribution";
+import { insightsKpiChartAdapter } from "./chart-adapters/kpi";
+import { insightsLineChartAdapter } from "./chart-adapters/line";
+import { insightsMultiLineChartAdapter } from "./chart-adapters/multi-line";
+import { insightsTableChartAdapter } from "./chart-adapters/table";
+import { insightsContract } from "./contract";
+import { type InsightsClient, insightsQuery } from "./utils/query";
+
+interface InsightsAdapterProps {
+ baseUrl: string;
+ accessToken: string;
+ sourceType: string;
+}
+
+export const insightsAdapter = ({
+ baseUrl,
+ accessToken,
+ sourceType,
+}: InsightsAdapterProps): DataAdapter => {
+ const client: InsightsClient = initClient(insightsContract, {
+ baseUrl,
+ baseHeaders: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ return {
+ charts: {
+ table: insightsTableChartAdapter(client, sourceType),
+ bar: insightsBarChartAdapter(client, sourceType),
+ distribution: insightsDistributionChartAdapter(client, sourceType),
+ line: insightsLineChartAdapter(client, sourceType),
+ multiLine: insightsMultiLineChartAdapter(client, sourceType),
+ kpi: insightsKpiChartAdapter(client, sourceType),
+ },
+ filters: {
+ list: (filter: ListFilter) => {
+ return queryOptions({
+ queryKey: [sourceType, filter.field.id] as readonly unknown[],
+ queryFn: async () => {
+ const response = await insightsQuery(
+ client,
+ sourceType,
+ {
+ filters: [],
+ groupBy: [filter.field.id],
+ aggregates: [],
+ },
+ "Failed to fetch filter values",
+ );
+
+ const values = response[filter.field.id]?.values ?? [];
+ return z.array(PrimitiveValueSchema).parse(values);
+ },
+ });
+ },
+ },
+ };
+};
diff --git a/apps/apollo-vertex/registry/insights-adapter/chart-adapters/bar.ts b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/bar.ts
new file mode 100644
index 000000000..38c237571
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/bar.ts
@@ -0,0 +1,56 @@
+import { queryOptions } from "@tanstack/react-query";
+import { assertDefined } from "@/lib/asserts/assert-defined";
+import {
+ type DataAdapter,
+ mapConfigFilterToFilterValues,
+ mapResponseToChartData,
+} from "@/lib/charts-core";
+import { assertInsightsConfigurationSupported } from "../utils/assert-configuration-supported";
+import { mapMetricToInsightsAggregate } from "../utils/metric-aggregate";
+import { type InsightsClient, insightsQuery } from "../utils/query";
+import { createInsightsChartQueryOptions } from "../utils/query-options";
+
+export const insightsBarChartAdapter = (
+ client: InsightsClient,
+ sourceType: string,
+): DataAdapter["charts"]["bar"] => {
+ return (configuration, dataModel) => {
+ assertInsightsConfigurationSupported(configuration);
+ const dimensionIds = configuration.dimensions;
+ const metrics = configuration.metrics.map((id) =>
+ assertDefined(
+ dataModel.metrics.find((m) => m.id === id),
+ `Metric ${id} not found in dataModel`,
+ ),
+ );
+ const aggregates = metrics.map((m) => mapMetricToInsightsAggregate(m));
+
+ const requestBody = createInsightsChartQueryOptions({
+ groupBy: dimensionIds,
+ aggregates,
+ sortBy: null,
+ filters: (configuration.filters ?? []).map((f) =>
+ mapConfigFilterToFilterValues(f),
+ ),
+ filterTableId: configuration.filterTableId,
+ });
+
+ return queryOptions({
+ queryKey: [sourceType, "bar", JSON.stringify(requestBody)],
+ queryFn: async () => {
+ const body = await insightsQuery(
+ client,
+ sourceType,
+ requestBody,
+ "Failed to fetch bar chart data",
+ );
+
+ return mapResponseToChartData({
+ data: body,
+ dimensions: dimensionIds,
+ metrics: aggregates.map((a) => a.id),
+ });
+ },
+ });
+ };
+};
diff --git a/apps/apollo-vertex/registry/insights-adapter/chart-adapters/distribution.ts b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/distribution.ts
new file mode 100644
index 000000000..b27956c26
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/distribution.ts
@@ -0,0 +1,168 @@
+import { queryOptions } from "@tanstack/react-query";
+import { DateTime } from "luxon";
+import { z } from "zod";
+import { assert } from "@/lib/asserts/assert";
+import { assertDefined } from "@/lib/asserts/assert-defined";
+import {
+ type DataAdapter,
+ type DistributionChartData,
+ mapConfigFilterToFilterValues,
+} from "@/lib/charts-core";
+import { assertInsightsConfigurationSupported } from "../utils/assert-configuration-supported";
+import {
+ calculateDatetimeBins,
+ calculateNumericBins,
+ findBinCutoffPoints,
+} from "../utils/binning";
+import { mapMetricToInsightsAggregate } from "../utils/metric-aggregate";
+import { type InsightsClient, insightsQuery } from "../utils/query";
+import { createInsightsChartQueryOptions } from "../utils/query-options";
+
+export const insightsDistributionChartAdapter = (
+ client: InsightsClient,
+ sourceType: string,
+): DataAdapter["charts"]["distribution"] => {
+ return (configuration, dataModel) => {
+ assertInsightsConfigurationSupported(configuration);
+ const dimensionId = assertDefined(
+ configuration.dimensions[0],
+ "Distribution chart must have at least one dimension",
+ );
+ const dimension = assertDefined(
+ dataModel.dimensions.find((d) => d.id === dimensionId),
+ `Dimension ${dimensionId} not found in dataModel`,
+ );
+ assert(
+ dimension.type === "datetime" || dimension.type === "numeric",
+ "Dimension type must be datetime or numeric",
+ );
+
+ const firstMetricId = assertDefined(
+ configuration.metrics[0],
+ "Distribution chart must have at least one metric",
+ );
+ const firstMetric = assertDefined(
+ dataModel.metrics.find((m) => m.id === firstMetricId),
+ `Metric ${firstMetricId} not found in dataModel`,
+ );
+ const aggregate = mapMetricToInsightsAggregate(firstMetric);
+
+ const filters = (configuration.filters ?? []).map((f) =>
+ mapConfigFilterToFilterValues(f),
+ );
+ const filterTableId = configuration.filterTableId;
+
+ const minMaxRequest = createInsightsChartQueryOptions({
+ groupBy: [],
+ aggregates: [
+ {
+ id: "min",
+ expression: {
+ type: "aggregate",
+ aggregation: "MIN",
+ argument: dimensionId,
+ },
+ },
+ {
+ id: "max",
+ expression: {
+ type: "aggregate",
+ aggregation: "MAX",
+ argument: dimensionId,
+ },
+ },
+ ],
+ sortBy: null,
+ filters,
+ filterTableId,
+ });
+
+ return queryOptions<
+ DistributionChartData,
+ Error,
+ DistributionChartData,
+ string[]
+ >({
+ queryKey: [
+ sourceType,
+ "distribution",
+ JSON.stringify(minMaxRequest),
+ JSON.stringify(aggregate),
+ ],
+ queryFn: async () => {
+ const response = await insightsQuery(
+ client,
+ sourceType,
+ minMaxRequest,
+ "Failed to fetch min/max",
+ );
+
+ const bins =
+ dimension.type === "datetime"
+ ? (() => {
+ const minStr = z
+ .string()
+ .nullable()
+ .parse(response.min?.values?.[0] ?? null);
+ const maxStr = z
+ .string()
+ .nullable()
+ .parse(response.max?.values?.[0] ?? null);
+ if (!minStr || !maxStr) {
+ return null;
+ }
+ return calculateDatetimeBins({
+ min: DateTime.fromISO(minStr, { zone: "utc" }),
+ max: DateTime.fromISO(maxStr, { zone: "utc" }),
+ });
+ })()
+ : (() => {
+ const minNum = z
+ .number()
+ .nullable()
+ .parse(response.min?.values?.[0] ?? null);
+ const maxNum = z
+ .number()
+ .nullable()
+ .parse(response.max?.values?.[0] ?? null);
+ if (minNum == null || maxNum == null) {
+ return null;
+ }
+ return calculateNumericBins({ min: minNum, max: maxNum });
+ })();
+
+ if (!bins) {
+ return { values: [], bins: [] };
+ }
+
+ const cutoffPoints = findBinCutoffPoints(bins);
+ if (cutoffPoints.length === 0) {
+ return { values: [], bins: [] };
+ }
+
+ const binsRequest = createInsightsChartQueryOptions({
+ groupBy: [],
+ aggregates: [aggregate],
+ sortBy: null,
+ filters,
+ filterTableId,
+ binning: { bins: cutoffPoints, dimension: dimensionId },
+ });
+
+ const dataResponse = await insightsQuery(
+ client,
+ sourceType,
+ binsRequest,
+ "Failed to fetch distribution data",
+ );
+
+ const values =
+ dataResponse[aggregate.id]?.values?.map(
+ (value) => z.number().nullable().parse(value) ?? 0,
+ ) ?? [];
+
+ return { values, bins };
+ },
+ });
+ };
+};
diff --git a/apps/apollo-vertex/registry/insights-adapter/chart-adapters/kpi.ts b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/kpi.ts
new file mode 100644
index 000000000..8315ef741
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/kpi.ts
@@ -0,0 +1,60 @@
+import { queryOptions } from "@tanstack/react-query";
+import { z } from "zod";
+import { assertDefined } from "@/lib/asserts/assert-defined";
+import {
+ type DataAdapter,
+ type KpiChartData,
+ mapConfigFilterToFilterValues,
+} from "@/lib/charts-core";
+import { assertInsightsConfigurationSupported } from "../utils/assert-configuration-supported";
+import { mapMetricToInsightsAggregate } from "../utils/metric-aggregate";
+import { type InsightsClient, insightsQuery } from "../utils/query";
+import { createInsightsChartQueryOptions } from "../utils/query-options";
+
+export const insightsKpiChartAdapter = (
+ client: InsightsClient,
+ sourceType: string,
+): DataAdapter["charts"]["kpi"] => {
+ return (configuration, dataModel) => {
+ assertInsightsConfigurationSupported(configuration);
+ const firstMetricId = assertDefined(
+ configuration.metrics[0],
+ "KPI chart must have at least one metric",
+ );
+ const firstMetric = assertDefined(
+ dataModel.metrics.find((m) => m.id === firstMetricId),
+ `Metric ${firstMetricId} not found in dataModel`,
+ );
+ const aggregate = mapMetricToInsightsAggregate(firstMetric);
+
+ const requestBody = createInsightsChartQueryOptions({
+ groupBy: [],
+ aggregates: [aggregate],
+ sortBy: null,
+ filters: (configuration.filters ?? []).map((f) =>
+ mapConfigFilterToFilterValues(f),
+ ),
+ filterTableId: configuration.filterTableId,
+ });
+
+ return queryOptions({
+ queryKey: [sourceType, "kpi", JSON.stringify(requestBody)],
+ queryFn: async () => {
+ const body = await insightsQuery(
+ client,
+ sourceType,
+ requestBody,
+ "Failed to fetch KPI data",
+ );
+
+ const value =
+ z
+ .number()
+ .nullable()
+ .parse(body[aggregate.id]?.values?.[0] ?? null) ?? 0;
+
+ return { data: [value], labels: [] };
+ },
+ });
+ };
+};
diff --git a/apps/apollo-vertex/registry/insights-adapter/chart-adapters/line.ts b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/line.ts
new file mode 100644
index 000000000..e14d16959
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/line.ts
@@ -0,0 +1,138 @@
+import { queryOptions } from "@tanstack/react-query";
+import { DateTime } from "luxon";
+import { z } from "zod";
+import { assert } from "@/lib/asserts/assert";
+import { assertDefined } from "@/lib/asserts/assert-defined";
+import {
+ type DataAdapter,
+ type LineChartData,
+ mapConfigFilterToFilterValues,
+} from "@/lib/charts-core";
+import { assertInsightsConfigurationSupported } from "../utils/assert-configuration-supported";
+import { calculateDatetimeBins, findBinCutoffPoints } from "../utils/binning";
+import { mapMetricToInsightsAggregate } from "../utils/metric-aggregate";
+import { type InsightsClient, insightsQuery } from "../utils/query";
+import { createInsightsChartQueryOptions } from "../utils/query-options";
+
+export const insightsLineChartAdapter = (
+ client: InsightsClient,
+ sourceType: string,
+): DataAdapter["charts"]["line"] => {
+ return (configuration, dataModel) => {
+ assertInsightsConfigurationSupported(configuration);
+ const firstDimensionId = assertDefined(
+ configuration.dimensions[0],
+ "Line chart must have at least one dimension",
+ );
+ const firstDimension = assertDefined(
+ dataModel.dimensions.find((d) => d.id === firstDimensionId),
+ `Dimension ${firstDimensionId} not found in dataModel`,
+ );
+ const firstMetricId = assertDefined(
+ configuration.metrics[0],
+ "Line chart must have at least one metric",
+ );
+ const firstMetric = assertDefined(
+ dataModel.metrics.find((m) => m.id === firstMetricId),
+ `Metric ${firstMetricId} not found in dataModel`,
+ );
+
+ assert(
+ firstDimension.type === "datetime",
+ "Dimension type must be datetime",
+ );
+
+ const aggregate = mapMetricToInsightsAggregate(firstMetric);
+ const filters = (configuration.filters ?? []).map((f) =>
+ mapConfigFilterToFilterValues(f),
+ );
+ const filterTableId = configuration.filterTableId;
+
+ const minMaxRequest = createInsightsChartQueryOptions({
+ groupBy: [],
+ aggregates: [
+ {
+ id: "min",
+ expression: {
+ type: "aggregate",
+ aggregation: "MIN",
+ argument: firstDimension.id,
+ },
+ },
+ {
+ id: "max",
+ expression: {
+ type: "aggregate",
+ aggregation: "MAX",
+ argument: firstDimension.id,
+ },
+ },
+ ],
+ sortBy: null,
+ filters,
+ filterTableId,
+ });
+
+ return queryOptions({
+ queryKey: [
+ sourceType,
+ "line",
+ JSON.stringify(minMaxRequest),
+ JSON.stringify(aggregate),
+ ],
+ queryFn: async () => {
+ const response = await insightsQuery(
+ client,
+ sourceType,
+ minMaxRequest,
+ "Failed to fetch min/max",
+ );
+
+ const minStr = z
+ .string()
+ .nullable()
+ .parse(response.min?.values?.[0] ?? null);
+ const maxStr = z
+ .string()
+ .nullable()
+ .parse(response.max?.values?.[0] ?? null);
+ if (!minStr || !maxStr) {
+ return { values: [], bins: [] };
+ }
+
+ const min = DateTime.fromISO(minStr, { zone: "utc" });
+ const max = DateTime.fromISO(maxStr, { zone: "utc" });
+
+ const bins = calculateDatetimeBins({ min, max });
+
+ const cutoffPoints = findBinCutoffPoints(bins);
+ if (cutoffPoints.length === 0) {
+ return { values: [], bins: [] };
+ }
+
+ const binsRequest = createInsightsChartQueryOptions({
+ groupBy: [],
+ aggregates: [aggregate],
+ sortBy: null,
+ filters,
+ filterTableId,
+ binning: { bins: cutoffPoints, dimension: firstDimension.id },
+ });
+
+ const dataResponse = await insightsQuery(
+ client,
+ sourceType,
+ binsRequest,
+ "Failed to fetch line chart data",
+ );
+
+ const values =
+ dataResponse[aggregate.id]?.values?.map(
+ (value) => z.number().nullable().parse(value) ?? 0,
+ ) ?? [];
+
+ return { values, bins };
+ },
+ });
+ };
+};
diff --git a/apps/apollo-vertex/registry/insights-adapter/chart-adapters/multi-line.ts b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/multi-line.ts
new file mode 100644
index 000000000..a0849b08c
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/multi-line.ts
@@ -0,0 +1,150 @@
+import { queryOptions } from "@tanstack/react-query";
+import { DateTime } from "luxon";
+import { z } from "zod";
+import { assert } from "@/lib/asserts/assert";
+import { assertDefined } from "@/lib/asserts/assert-defined";
+import {
+ type DataAdapter,
+ mapConfigFilterToFilterValues,
+ type MultiLineChartData,
+} from "@/lib/charts-core";
+import { assertInsightsConfigurationSupported } from "../utils/assert-configuration-supported";
+import { calculateDatetimeBins, findBinCutoffPoints } from "../utils/binning";
+import { mapMetricToInsightsAggregate } from "../utils/metric-aggregate";
+import { type InsightsClient, insightsQuery } from "../utils/query";
+import { createInsightsChartQueryOptions } from "../utils/query-options";
+
+export const insightsMultiLineChartAdapter = (
+ client: InsightsClient,
+ sourceType: string,
+): DataAdapter["charts"]["multiLine"] => {
+ return (configuration, dataModel) => {
+ assertInsightsConfigurationSupported(configuration);
+ const firstDimensionId = assertDefined(
+ configuration.dimensions[0],
+ "Multi line chart must have at least one dimension",
+ );
+ const firstDimension = assertDefined(
+ dataModel.dimensions.find((d) => d.id === firstDimensionId),
+ `Dimension ${firstDimensionId} not found in dataModel`,
+ );
+
+ assert(
+ firstDimension.type === "datetime",
+ "Dimension type must be datetime",
+ );
+
+ const metrics = configuration.metrics.map((id) =>
+ assertDefined(
+ dataModel.metrics.find((m) => m.id === id),
+ `Metric ${id} not found in dataModel`,
+ ),
+ );
+ const aggregates = metrics.map((m) => mapMetricToInsightsAggregate(m));
+ const filters = (configuration.filters ?? []).map((f) =>
+ mapConfigFilterToFilterValues(f),
+ );
+ const filterTableId = configuration.filterTableId;
+
+ const minMaxRequest = createInsightsChartQueryOptions({
+ groupBy: [],
+ aggregates: [
+ {
+ id: "min",
+ expression: {
+ type: "aggregate",
+ aggregation: "MIN",
+ argument: firstDimension.id,
+ },
+ },
+ {
+ id: "max",
+ expression: {
+ type: "aggregate",
+ aggregation: "MAX",
+ argument: firstDimension.id,
+ },
+ },
+ ],
+ sortBy: null,
+ filters,
+ filterTableId,
+ });
+
+ return queryOptions<
+ MultiLineChartData,
+ Error,
+ MultiLineChartData,
+ string[]
+ >({
+ queryKey: [
+ sourceType,
+ "multi-line",
+ JSON.stringify(minMaxRequest),
+ ...aggregates.map((a) => JSON.stringify(a)),
+ ],
+ queryFn: async () => {
+ const response = await insightsQuery(
+ client,
+ sourceType,
+ minMaxRequest,
+ "Failed to fetch min/max",
+ );
+
+ const minStr = z
+ .string()
+ .nullable()
+ .parse(response.min?.values?.[0] ?? null);
+ const maxStr = z
+ .string()
+ .nullable()
+ .parse(response.max?.values?.[0] ?? null);
+ if (!minStr || !maxStr) {
+ return { seriesByMetricId: {}, bins: [] };
+ }
+
+ const min = DateTime.fromISO(minStr, { zone: "utc" });
+ const max = DateTime.fromISO(maxStr, { zone: "utc" });
+
+ const bins = calculateDatetimeBins({ min, max });
+
+ const cutoffPoints = findBinCutoffPoints(bins);
+ if (cutoffPoints.length === 0) {
+ return { seriesByMetricId: {}, bins: [] };
+ }
+
+ const results = await Promise.all(
+ aggregates.map((agg) =>
+ insightsQuery(
+ client,
+ sourceType,
+ createInsightsChartQueryOptions({
+ groupBy: [],
+ aggregates: [agg],
+ sortBy: null,
+ filters,
+ filterTableId,
+ binning: { bins: cutoffPoints, dimension: firstDimension.id },
+ }),
+ "Failed to fetch multi-line chart data",
+ ),
+ ),
+ );
+
+ const seriesByMetricId: Record = {};
+ for (const [idx, agg] of aggregates.entries()) {
+ const dataResponse = assertDefined(
+ results[idx],
+ "Missing results for metric",
+ );
+ seriesByMetricId[agg.id] =
+ dataResponse[agg.id]?.values?.map(
+ (value) => z.number().nullable().parse(value) ?? 0,
+ ) ?? [];
+ }
+
+ return { seriesByMetricId, bins };
+ },
+ });
+ };
+};
diff --git a/apps/apollo-vertex/registry/insights-adapter/chart-adapters/table.ts b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/table.ts
new file mode 100644
index 000000000..c0dffa776
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/table.ts
@@ -0,0 +1,44 @@
+import { queryOptions } from "@tanstack/react-query";
+import {
+ type DataAdapter,
+ mapConfigFilterToFilterValues,
+ mapResponseToChartData,
+} from "@/lib/charts-core";
+import { assertInsightsConfigurationSupported } from "../utils/assert-configuration-supported";
+import { type InsightsClient, insightsQuery } from "../utils/query";
+import { createInsightsChartQueryOptions } from "../utils/query-options";
+
+export const insightsTableChartAdapter = (
+ client: InsightsClient,
+ sourceType: string,
+): DataAdapter["charts"]["table"] => {
+ return (configuration, _dataModel, state) => {
+ assertInsightsConfigurationSupported(configuration);
+ const dimensionIds = configuration.dimensions;
+ const requestBody = createInsightsChartQueryOptions({
+ groupBy: dimensionIds,
+ sortBy: state.sortBy,
+ filters: (configuration.filters ?? []).map((f) =>
+ mapConfigFilterToFilterValues(f),
+ ),
+ filterTableId: configuration.filterTableId,
+ });
+
+ return queryOptions({
+ queryKey: [sourceType, "table", JSON.stringify(requestBody)],
+ queryFn: async () => {
+ const body = await insightsQuery(
+ client,
+ sourceType,
+ requestBody,
+ "Failed to fetch table data",
+ );
+
+ return mapResponseToChartData({
+ data: body,
+ dimensions: dimensionIds,
+ });
+ },
+ });
+ };
+};
diff --git a/apps/apollo-vertex/registry/insights-adapter/contract.ts b/apps/apollo-vertex/registry/insights-adapter/contract.ts
new file mode 100644
index 000000000..8d6d789e8
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/contract.ts
@@ -0,0 +1,28 @@
+import { initContract } from "@ts-rest/core";
+import { z } from "zod";
+import { DataQueryResponseSchema } from "@/lib/charts-core";
+import { InsightsQueryRequestSchema } from "./schemas/query-schema";
+
+const c = initContract();
+
+export const insightsContract = c.router(
+ {
+ query: {
+ method: "POST",
+ path: "/standalone-query/:sourceType",
+ responses: {
+ 200: DataQueryResponseSchema,
+ 400: z.object({
+ error: z.string(),
+ }),
+ },
+ pathParams: z.object({
+ sourceType: z.string(),
+ }),
+ body: InsightsQueryRequestSchema,
+ },
+ },
+ {
+ strictStatusCodes: true,
+ },
+);
diff --git a/apps/apollo-vertex/registry/insights-adapter/insights-adapter.ts b/apps/apollo-vertex/registry/insights-adapter/insights-adapter.ts
new file mode 100644
index 000000000..3ae88e55a
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/insights-adapter.ts
@@ -0,0 +1,2 @@
+export { insightsAdapter } from "./adapter";
+export { insightsContract } from "./contract";
diff --git a/apps/apollo-vertex/registry/insights-adapter/schemas/aggregate-fragment-schema.ts b/apps/apollo-vertex/registry/insights-adapter/schemas/aggregate-fragment-schema.ts
new file mode 100644
index 000000000..0dc3e8733
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/schemas/aggregate-fragment-schema.ts
@@ -0,0 +1,167 @@
+import { z } from "zod";
+
+const AggregationKind = z.enum([
+ "ANY",
+ "DISTINCT_COUNT",
+ "COUNT",
+ "CASE_AVERAGE",
+ "AVERAGE",
+ "MIN",
+ "MAX",
+ "PERCENTAGE",
+ "SUM",
+ "MEDIAN",
+ "PERCENTILE",
+]);
+
+const AggregateSchema = z.object({
+ type: z.literal("aggregate"),
+ aggregation: AggregationKind,
+ argument: z.string(),
+ filters: z.array(z.any()).optional(),
+ percentileRank: z
+ .object({
+ kind: z.literal("Constant"),
+ percentileRank: z.number(),
+ })
+ .optional(),
+});
+
+const NumericConstantSchema = z.object({
+ type: z.literal("constant"),
+ value: z.number(),
+ dataType: z.enum(["currency", "numeric", "percentage", "duration"]),
+});
+const StringConstantSchema = z.object({
+ type: z.literal("constant"),
+ value: z.string(),
+ dataType: z.enum(["nominal", "datetime"]),
+});
+
+const BooleanConstantSchema = z.object({
+ type: z.literal("constant"),
+ value: z.boolean(),
+ dataType: z.literal("boolean"),
+});
+
+const ConstantSchema = z.union([
+ NumericConstantSchema,
+ StringConstantSchema,
+ BooleanConstantSchema,
+]);
+
+const OperandFragmentSchema: z.ZodType = z.lazy(() =>
+ z.union([
+ AggregateSchema,
+ ConstantSchema,
+ OperatorSchema,
+ ReferenceSchema,
+ FunctionSchema,
+ ]),
+);
+
+const OperatorSchema = z.object({
+ type: z.literal("operator"),
+ operation: z.enum([
+ "divide",
+ "percentage",
+ "multiply",
+ "subtract",
+ "add",
+ "eq",
+ "ne",
+ "gt",
+ "ge",
+ "lt",
+ "le",
+ "and",
+ "or",
+ ]),
+ left: OperandFragmentSchema,
+ right: OperandFragmentSchema,
+});
+
+const ReferenceSchema = z.object({
+ type: z.literal("reference"),
+ reference: z.string(),
+});
+
+const IsNullNotNullFunctionSchema = z.object({
+ type: z.literal("function"),
+ function: z.union([z.literal("isnull"), z.literal("isnotnull")]),
+ operand: OperandFragmentSchema,
+});
+
+const IfThenFunctionSchema = z.object({
+ type: z.literal("function"),
+ function: z.literal("if"),
+ guard: OperandFragmentSchema,
+ whenTrue: OperandFragmentSchema,
+ whenFalse: OperandFragmentSchema.optional(),
+});
+
+const NotFunctionSchema = z.object({
+ type: z.literal("function"),
+ function: z.literal("not"),
+ operand: OperandFragmentSchema,
+});
+
+const LeftRightFunctionSchema = z.object({
+ type: z.literal("function"),
+ function: z.enum(["left", "right"]),
+ expression: OperandFragmentSchema,
+ length: OperandFragmentSchema,
+});
+
+const SubstringFunctionSchema = z.object({
+ type: z.literal("function"),
+ function: z.literal("substring"),
+ expression: OperandFragmentSchema,
+ start: OperandFragmentSchema,
+ length: OperandFragmentSchema,
+});
+
+const InFunctionSchema = z.object({
+ type: z.literal("function"),
+ function: z.literal("in"),
+ value: OperandFragmentSchema,
+ list: z.array(OperandFragmentSchema),
+});
+
+const ConcatFunctionSchema = z.object({
+ type: z.literal("function"),
+ function: z.literal("concat"),
+ list: z.array(OperandFragmentSchema),
+});
+
+const CoalesceFunctionSchema = z.object({
+ type: z.literal("function"),
+ function: z.literal("coalesce"),
+ list: z.array(OperandFragmentSchema),
+});
+
+const FunctionSchema = z.union([
+ IsNullNotNullFunctionSchema,
+ NotFunctionSchema,
+ IfThenFunctionSchema,
+ SubstringFunctionSchema,
+ LeftRightFunctionSchema,
+ InFunctionSchema,
+ ConcatFunctionSchema,
+ CoalesceFunctionSchema,
+]);
+
+const ExpressionFragmentSchema = z.union([
+ AggregateSchema,
+ OperatorSchema,
+ ConstantSchema,
+ ReferenceSchema,
+ FunctionSchema,
+]);
+
+export const AggregateFragmentSchema = z.object({
+ id: z.string(),
+ expression: ExpressionFragmentSchema,
+});
+
+export type AggregateFragment = z.infer;
diff --git a/apps/apollo-vertex/registry/insights-adapter/schemas/filter-fragment-schema.ts b/apps/apollo-vertex/registry/insights-adapter/schemas/filter-fragment-schema.ts
new file mode 100644
index 000000000..95b8a754d
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/schemas/filter-fragment-schema.ts
@@ -0,0 +1,67 @@
+import { z } from "zod";
+
+const PrimitiveValueSchema = z.union([
+ z.string(),
+ z.boolean(),
+ z.number(),
+ z.null(),
+]);
+
+const ListFilterFragmentSchema = z.object({
+ dimension: z.string(),
+ values: z.array(PrimitiveValueSchema),
+ type: z.union([
+ z.literal("string"),
+ z.literal("boolean"),
+ z.literal("numeric"),
+ ]),
+ kind: z.literal("values"),
+ invert: z.boolean(),
+});
+export type ListFilterFragment = z.infer;
+
+const SearchFilterFragmentSchema = z.object({
+ dimension: z.string(),
+ pattern: z.string(),
+ filterType: z.union([
+ z.literal("default"),
+ z.literal("startsWith"),
+ z.literal("endsWith"),
+ ]),
+ type: z.literal("string"),
+ kind: z.literal("search"),
+ invert: z.boolean().optional(),
+});
+export type SearchFilterFragment = z.infer;
+
+const PeriodFilterFragmentSchema = z.object({
+ dimension: z.string(),
+ type: z.literal("datetime"),
+ range: z.object({
+ inclusive: z.boolean(),
+ start: z.string(),
+ end: z.string(),
+ }),
+ kind: z.literal("range"),
+});
+export type PeriodFilterFragment = z.infer;
+
+const RangeFilterFragmentSchema = z.object({
+ dimension: z.string(),
+ type: z.literal("numeric"),
+ range: z.object({
+ inclusive: z.boolean(),
+ start: z.number().optional(),
+ end: z.number().optional(),
+ }),
+ kind: z.literal("range"),
+});
+export type RangeFilterFragment = z.infer;
+
+export const FilterFragmentSchema = z.union([
+ ListFilterFragmentSchema,
+ PeriodFilterFragmentSchema,
+ RangeFilterFragmentSchema,
+ SearchFilterFragmentSchema,
+]);
+export type FilterFragment = z.infer;
diff --git a/apps/apollo-vertex/registry/insights-adapter/schemas/filter-request-schema.ts b/apps/apollo-vertex/registry/insights-adapter/schemas/filter-request-schema.ts
new file mode 100644
index 000000000..bc1406d4c
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/schemas/filter-request-schema.ts
@@ -0,0 +1,22 @@
+import { z } from "zod";
+import { FilterFragmentSchema } from "./filter-fragment-schema";
+
+const TableFragmentSchema = z.object({
+ kind: z.literal("tableWith"),
+ table: z.string(),
+ invert: z.boolean().optional(),
+ filters: z.array(FilterFragmentSchema),
+});
+
+const MainTableSchema = z.object({
+ kind: z.literal("primarykey"),
+ table: z.string(),
+});
+
+export const FilterRequestSchema = z.union([
+ FilterFragmentSchema,
+ TableFragmentSchema,
+ MainTableSchema,
+]);
+
+export type FilterRequest = z.infer;
diff --git a/apps/apollo-vertex/registry/insights-adapter/schemas/query-schema.ts b/apps/apollo-vertex/registry/insights-adapter/schemas/query-schema.ts
new file mode 100644
index 000000000..982167971
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/schemas/query-schema.ts
@@ -0,0 +1,33 @@
+import { z } from "zod";
+import { AggregateFragmentSchema } from "./aggregate-fragment-schema";
+import { FilterRequestSchema } from "./filter-request-schema";
+
+export const InsightsQueryRequestSchema = z.object({
+ filters: z.array(z.array(FilterRequestSchema)),
+ groupBy: z.array(z.string()),
+ aggregates: z.array(AggregateFragmentSchema),
+ sort: z
+ .array(
+ z.object({
+ field: z.string(),
+ direction: z.enum(["asc", "desc"]),
+ }),
+ )
+ .optional(),
+ binning: z
+ .object({
+ bins: z.array(z.union([z.string(), z.number()])),
+ dimension: z.string(),
+ extraBins: z.enum(["none", "null"]),
+ })
+ .optional(),
+ stacks: z
+ .object({
+ field: z.string(),
+ orderByMetric: z.string(),
+ maxCount: z.number(),
+ })
+ .optional(),
+});
+
+export type InsightsQueryRequest = z.infer;
diff --git a/apps/apollo-vertex/registry/insights-adapter/utils/assert-configuration-supported.ts b/apps/apollo-vertex/registry/insights-adapter/utils/assert-configuration-supported.ts
new file mode 100644
index 000000000..3cc81b3bb
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/utils/assert-configuration-supported.ts
@@ -0,0 +1,20 @@
+interface ConfigurationWithJoinsAndFrom {
+ id: string;
+ joins?: ReadonlyArray;
+ from?: unknown;
+}
+
+export function assertInsightsConfigurationSupported(
+ configuration: ConfigurationWithJoinsAndFrom,
+): void {
+ if (configuration.joins && configuration.joins.length > 0) {
+ throw new Error(
+ `Insights does not support joins (chart configuration "${configuration.id}").`,
+ );
+ }
+ if (configuration.from) {
+ throw new Error(
+ `Insights does not support "from" (chart configuration "${configuration.id}").`,
+ );
+ }
+}
diff --git a/apps/apollo-vertex/registry/insights-adapter/utils/binning.ts b/apps/apollo-vertex/registry/insights-adapter/utils/binning.ts
new file mode 100644
index 000000000..bd60fad1d
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/utils/binning.ts
@@ -0,0 +1,97 @@
+import {
+ type DateTime,
+ type DateTimeUnit,
+ type Duration,
+ Interval,
+} from "luxon";
+import { niceDurationNumbers, niceNumbers } from "@/lib/charts-core";
+
+export interface NumericBin {
+ start: number;
+ end: number;
+}
+
+function findHighestUnit(duration: Duration): DateTimeUnit {
+ if (duration.years > 0) return "year";
+ if (duration.months > 0) return "month";
+ if (duration.days > 0) return "day";
+ if (duration.hours > 0) return "hour";
+ if (duration.minutes > 0) return "minute";
+ if (duration.seconds > 0) return "second";
+ return "millisecond";
+}
+
+function createDatetimeBins({
+ start,
+ end,
+ highestUnit,
+ bestBinSize,
+}: {
+ start: DateTime;
+ end: DateTime;
+ highestUnit: DateTimeUnit;
+ bestBinSize: Duration;
+}): Interval[] {
+ const bins: Interval[] = [];
+
+ let currentStart = start;
+ let currentEnd = currentStart.startOf(highestUnit).plus(bestBinSize);
+
+ while (currentEnd < end) {
+ bins.push(Interval.fromDateTimes(currentStart, currentEnd));
+ currentStart = currentEnd;
+ currentEnd = currentStart.plus(bestBinSize);
+ }
+ bins.push(Interval.fromDateTimes(currentStart, end));
+ return bins;
+}
+
+export function calculateDatetimeBins({
+ min,
+ max,
+}: {
+ min: DateTime;
+ max: DateTime;
+}): Interval[] {
+ if (min.equals(max)) {
+ return [Interval.fromDateTimes(min, max)];
+ }
+
+ const bestBinSize = niceDurationNumbers({ min, max });
+ const highestUnit = findHighestUnit(bestBinSize);
+
+ return createDatetimeBins({
+ start: min,
+ end: max,
+ highestUnit,
+ bestBinSize,
+ });
+}
+
+export function calculateNumericBins({
+ min,
+ max,
+}: {
+ min: number;
+ max: number;
+}): NumericBin[] {
+ if (min === max) {
+ return [{ start: min, end: max }];
+ }
+
+ const { min: niceMin, binSize } = niceNumbers({ min, max });
+ const binCount = Math.ceil((max - niceMin) / binSize);
+
+ return Array.from({ length: binCount }, (_, idx) => ({
+ start: niceMin + binSize * idx,
+ end: niceMin + binSize * (idx + 1),
+ }));
+}
+
+export function findBinCutoffPoints(
+ bins: Array,
+): number[] {
+ return bins
+ .filter((_, idx) => idx < bins.length - 1)
+ .map(({ end }) => (typeof end === "number" ? end : end.toMillis()));
+}
diff --git a/apps/apollo-vertex/registry/insights-adapter/utils/filter-request.ts b/apps/apollo-vertex/registry/insights-adapter/utils/filter-request.ts
new file mode 100644
index 000000000..e699dadec
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/utils/filter-request.ts
@@ -0,0 +1,90 @@
+import type { FilterValues } from "@/lib/charts-core";
+import type {
+ FilterFragment,
+ ListFilterFragment,
+ PeriodFilterFragment,
+ RangeFilterFragment,
+ SearchFilterFragment,
+} from "../schemas/filter-fragment-schema";
+import type { FilterRequest } from "../schemas/filter-request-schema";
+
+function mapFilterValueToFragment(filterValue: FilterValues): FilterFragment {
+ switch (filterValue.type) {
+ case "list":
+ return {
+ values: filterValue.values,
+ kind: "values",
+ dimension: filterValue.field,
+ type:
+ filterValue.valueType === "number"
+ ? "numeric"
+ : filterValue.valueType,
+ invert: filterValue.invert ?? false,
+ } satisfies ListFilterFragment;
+ case "search":
+ return {
+ pattern: filterValue.pattern,
+ filterType: filterValue.searchFilterType,
+ kind: "search",
+ dimension: filterValue.field,
+ type: "string",
+ invert: false,
+ } satisfies SearchFilterFragment;
+ case "period":
+ return {
+ kind: "range",
+ dimension: filterValue.field,
+ type: "datetime",
+ range: {
+ inclusive: filterValue.range.inclusive ?? false,
+ start: filterValue.range.min.toISO() ?? "",
+ end: filterValue.range.max.toISO() ?? "",
+ },
+ } satisfies PeriodFilterFragment;
+ case "range":
+ return {
+ kind: "range",
+ dimension: filterValue.field,
+ type: "numeric",
+ range: {
+ inclusive: filterValue.range.inclusive ?? false,
+ ...(filterValue.range.min != null && {
+ start: filterValue.range.min,
+ }),
+ ...(filterValue.range.max != null && {
+ end: filterValue.range.max,
+ }),
+ },
+ } satisfies RangeFilterFragment;
+ }
+}
+
+export function mapFilterValuesToFragments(
+ filterValues: FilterValues[],
+): FilterFragment[] {
+ return filterValues.map((f) => mapFilterValueToFragment(f));
+}
+
+export function mapFilterValuesToFilterRequest(
+ filterValues: FilterValues[],
+ filterTableId?: string,
+): FilterRequest[][] {
+ const filters = mapFilterValuesToFragments(filterValues);
+ if (filters.length === 0) {
+ return [];
+ }
+
+ if (!filterTableId) {
+ return [filters];
+ }
+
+ return [
+ [
+ {
+ kind: "tableWith",
+ table: filterTableId,
+ filters,
+ },
+ ],
+ ];
+}
diff --git a/apps/apollo-vertex/registry/insights-adapter/utils/metric-aggregate.ts b/apps/apollo-vertex/registry/insights-adapter/utils/metric-aggregate.ts
new file mode 100644
index 000000000..70fbecba7
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/utils/metric-aggregate.ts
@@ -0,0 +1,21 @@
+import type { DataModelMetric } from "@/lib/charts-core";
+import type { AggregateFragment } from "../schemas/aggregate-fragment-schema";
+import { mapFilterValuesToFragments } from "./filter-request";
+
+export function mapMetricToInsightsAggregate(
+ metric: DataModelMetric,
+): AggregateFragment {
+ const expr = metric.expression;
+ const hasFilters = expr.filters != null && expr.filters.length > 0;
+
+ return {
+ id: metric.id,
+ expression: {
+ type: "aggregate",
+ aggregation: expr.aggregation,
+ argument: expr.argument.id,
+ ...(hasFilters &&
+ expr.filters && { filters: mapFilterValuesToFragments(expr.filters) }),
+ },
+ };
+}
diff --git a/apps/apollo-vertex/registry/insights-adapter/utils/query-options.ts b/apps/apollo-vertex/registry/insights-adapter/utils/query-options.ts
new file mode 100644
index 000000000..8ef92d863
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/utils/query-options.ts
@@ -0,0 +1,36 @@
+import type { FilterValues } from "@/lib/charts-core";
+import type { AggregateFragment } from "../schemas/aggregate-fragment-schema";
+import type { InsightsQueryRequest } from "../schemas/query-schema";
+import { mapFilterValuesToFilterRequest } from "./filter-request";
+
+interface InsightsChartQueryOptions {
+ groupBy: string[];
+ aggregates?: AggregateFragment[];
+ sortBy: { field: string; direction: "asc" | "desc" } | null;
+ filters?: FilterValues[];
+ filterTableId?: string;
+ binning?: { dimension: string; bins: number[] };
+}
+
+export function createInsightsChartQueryOptions({
+ groupBy,
+ aggregates = [],
+ sortBy,
+ filters = [],
+ filterTableId,
+ binning,
+}: InsightsChartQueryOptions): InsightsQueryRequest {
+ return {
+ filters: mapFilterValuesToFilterRequest(filters, filterTableId),
+ groupBy,
+ aggregates,
+ ...(binning && {
+ binning: {
+ bins: binning.bins,
+ dimension: binning.dimension,
+ extraBins: "none" as const,
+ },
+ }),
+ sort: sortBy ? [{ field: sortBy.field, direction: sortBy.direction }] : [],
+ };
+}
diff --git a/apps/apollo-vertex/registry/insights-adapter/utils/query.ts b/apps/apollo-vertex/registry/insights-adapter/utils/query.ts
new file mode 100644
index 000000000..34630758e
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/utils/query.ts
@@ -0,0 +1,35 @@
+import type { InitClientReturn } from "@ts-rest/core";
+import type { DataQueryResponse } from "@/lib/charts-core";
+import type { insightsContract } from "../contract";
+import type { InsightsQueryRequest } from "../schemas/query-schema";
+import { throwInsightsError } from "./throw-error";
+
+export type InsightsClient = InitClientReturn<
+ typeof insightsContract,
+ { baseUrl: string }
+>;
+
+type QueryResponse =
+ | { status: 200; body: DataQueryResponse }
+ | { status: 400; body: { error: string } };
+
+export async function insightsQuery(
+ client: InsightsClient,
+ sourceType: string,
+ body: InsightsQueryRequest,
+ errorMessage: string,
+): Promise {
+ // oxlint's tsgolint can't resolve ts-rest's InitClientReturn generic, so it
+ // sees client.query as `error`-typed; the cast restores the real shape.
+ // oxlint-disable-next-line typescript-eslint/no-unsafe-call, typescript-eslint/no-unsafe-type-assertion
+ const result = (await client.query({
+ params: { sourceType },
+ body,
+ })) as QueryResponse;
+
+ if (result.status !== 200) {
+ throwInsightsError(result, errorMessage);
+ }
+
+ return result.body;
+}
diff --git a/apps/apollo-vertex/registry/insights-adapter/utils/throw-error.ts b/apps/apollo-vertex/registry/insights-adapter/utils/throw-error.ts
new file mode 100644
index 000000000..21ca1e8bb
--- /dev/null
+++ b/apps/apollo-vertex/registry/insights-adapter/utils/throw-error.ts
@@ -0,0 +1,6 @@
+export function throwInsightsError(
+ result: { status: 400; body: { error: string } },
+ fallback: string,
+): never {
+ throw new Error(result.body.error || fallback);
+}
diff --git a/apps/apollo-vertex/registry/kpi-chart/kpi-chart-with-adapter.tsx b/apps/apollo-vertex/registry/kpi-chart/kpi-chart-with-adapter.tsx
index 5274728ca..e54531de8 100644
--- a/apps/apollo-vertex/registry/kpi-chart/kpi-chart-with-adapter.tsx
+++ b/apps/apollo-vertex/registry/kpi-chart/kpi-chart-with-adapter.tsx
@@ -62,7 +62,7 @@ function KpiChartResolver({
const numericValue =
rawValue == null ? 0 : assertNumber(rawValue, "KPI numeric value");
const valueText = metric
- ? formatMetricValue(language, numericValue, metric.aggregation)
+ ? formatMetricValue(language, numericValue, metric.expression)
: "";
return (
diff --git a/apps/apollo-vertex/registry/line-chart/line-chart-with-adapter.tsx b/apps/apollo-vertex/registry/line-chart/line-chart-with-adapter.tsx
index 73397e51f..2f81240b2 100644
--- a/apps/apollo-vertex/registry/line-chart/line-chart-with-adapter.tsx
+++ b/apps/apollo-vertex/registry/line-chart/line-chart-with-adapter.tsx
@@ -95,7 +95,7 @@ function LineChartResolver({
data={data}
seriesLabel={metric.display}
formatValue={(value) =>
- formatMetricValue(language, value, metric.aggregation)
+ formatMetricValue(language, value, metric.expression)
}
/>
diff --git a/apps/apollo-vertex/registry/multi-line-chart/multi-line-chart-with-adapter.tsx b/apps/apollo-vertex/registry/multi-line-chart/multi-line-chart-with-adapter.tsx
index f6d58c902..856c79265 100644
--- a/apps/apollo-vertex/registry/multi-line-chart/multi-line-chart-with-adapter.tsx
+++ b/apps/apollo-vertex/registry/multi-line-chart/multi-line-chart-with-adapter.tsx
@@ -114,8 +114,8 @@ function MultiLineChartResolver({
color,
axis: idx === 0 ? "left" : "right",
formatValue: (value: number) =>
- formatMetricValue(language, value, metric.aggregation),
- totalText: formatMetricValue(language, total, metric.aggregation),
+ formatMetricValue(language, value, metric.expression),
+ totalText: formatMetricValue(language, total, metric.expression),
totalLabel: t("total_metric", { metric: metric.display }),
};
});
diff --git a/apps/apollo-vertex/tsconfig.json b/apps/apollo-vertex/tsconfig.json
index 2923adb6e..10c7ae5b6 100644
--- a/apps/apollo-vertex/tsconfig.json
+++ b/apps/apollo-vertex/tsconfig.json
@@ -36,6 +36,9 @@
"@/lib/data-fabric-adapter": [
"./registry/data-fabric-adapter/data-fabric-adapter"
],
+ "@/lib/insights-adapter": [
+ "./registry/insights-adapter/insights-adapter"
+ ],
"@/components/ui/line-chart": ["./registry/line-chart/line-chart"],
"@/components/ui/multi-line-chart": [
"./registry/multi-line-chart/multi-line-chart"