From 62c8b151a1c7537fe629d66e2aa789124f8e38ab Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Tue, 4 Aug 2026 17:16:21 +0200 Subject: [PATCH] fix(charts): reject long-format results in bar and line (#1400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `category, series, value` result — what GROUP BY a, b naturally produces — rendered a chart that looked plausible and was wrong. resolveValueKeys treats every non-label column as a value series, so `series` became a series whose cells all coerced to null: duplicated x labels, a ghost legend entry with no bars, and no stacking despite stackMode being set. Line drew one value line zig-zagging across repeated x values, which reads as a spiky time series but is artifact. validateNumericValueColumns rejects a value column that has non-null cells and none that parse, naming it and showing the pivot that fixes it. An all-null column stays legal — that is a sparse series, what a LEFT JOIN produces, and the reason collectAllKeys unions keys. Resolution goes through resolveValueKeys, the same call the transform makes, so the validator cannot disagree with what is plotted. validate() now takes the column mapping, as transformWithMapping already did. Without it the validator has to assume positional defaults and would reject a result whose text columns the user had already mapped away. The 11 reference tiles using this shape are rewritten to wide format so the stackMode tiles demonstrate real stacking. page-line-v-28's rightAxisSeries: "shipped" now names a column that exists. Closes #1400 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../__tests__/card-container-states.test.tsx | 81 ++++++++++++++++ app/src/components/card-container.tsx | 9 +- app/src/lib/plugin/chart-plugin-registry.ts | 8 +- app/src/plugins/bar/transform.ts | 8 +- app/src/plugins/line/transform.ts | 8 +- .../plugins/transforms/__tests__/bar.test.ts | 31 ++++++ .../plugins/transforms/__tests__/line.test.ts | 32 +++++++ .../transforms/__tests__/shared.test.ts | 94 +++++++++++++++++++ app/src/plugins/transforms/shared-utils.ts | 48 ++++++++++ scripts/demo/chart-reference.json | 22 ++--- 11 files changed, 322 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c2a4eb9..279b8b40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Chart-experience release: chart authoring, editing, rule-based styling and click ### Fixed +- A long-format result — `category, series, value`, what `GROUP BY a, b` naturally produces — rendered a bar or line chart that looked plausible and was wrong. `resolveValueKeys` treats every non-label column as a value series, so the `series` column became a series of its own whose cells all coerced to null: 12 bars with duplicated x labels, a ghost legend swatch with no bars, and **no stacking at all** despite `stackMode` being set. Line was worse — a single revenue line drawn across duplicated x values, interleaving delivered -> shipped -> delivered, which reads as a violently spiky time series but is pure artifact. Nothing marked either as an error, and the seeded reference dashboard itself demonstrated two `stackMode` options with this exact query shape, which is how easy it is to fall into. Bar and line now reject a result whose plotted value columns contain no numeric values, naming the offending column and showing the pivot that fixes it. A column that is entirely null stays legal — that is a sparse series, what a LEFT JOIN produces. `validate` now receives the same column mapping as the transform, so a result whose text columns the user already mapped away is not wrongly rejected. The 11 affected reference tiles are rewritten to wide format, so the stacking options finally demonstrate stacking (#1400) - Overlays ignored `prefers-reduced-motion`. All ten Radix primitives (dialog, alert-dialog, sheet, popover, tooltip, toast, select, dropdown-menu, context-menu, navigation-menu) animated unconditionally, even though spinner, progress and skeleton had honoured the preference since the component audit. Per-component `motion-reduce:animate-none` cannot fix this: the overlays animate through data-attribute variants like `data-[state=open]:animate-in`, which compile to `.class[data-state=open]` — specificity (0,2,0) — while the `motion-reduce:` utility is (0,1,0) and a media query adds no specificity, so the guard loses the cascade. The reset now lives once in `design-tokens.css`, the only stylesheet both packages import. It also fixed a CI flake nobody had connected to accessibility: Radix keeps an overlay mounted until its exit animation reports `animationend`, and when an NVL/WebGL widget mounted alongside the close that animation stalled, leaving `data-state="closed"` dialogs visible past a 5s budget — every "flaky graph chart" failure on `dev` was this, never a graph assertion. The E2E suite now runs as a reduced-motion user, so the accessibility branch is exercised in CI rather than merely declared (#1458) - The "Sync to URL" toggle on a parameter-select widget did nothing. `buildUrlParams()` was called with no exclude set and `extractNoSyncParams()` — the helper written to supply one — had **zero production callers**, only its own unit tests. Switching the toggle off still put the value in the address bar. URL sync is now **opt-in**: only a parameter whose widget sets `syncToUrl: true` reaches the query string. **Breaking for shared links** — a URL carrying `?param_…` for a widget that never opted in will no longer reproduce those values, and the parameter is stripped from an inbound URL rather than silently ignored (#1388) - Leaflet's zoom-transition timer fired after the map was torn down, throwing `Cannot read properties of undefined (reading '_leaflet_pos')`. Four such unhandled errors made the entire Storybook browser project exit 1 even though every story passed, which is why the visual-regression gate could only be pointed at two files. The timer is now disarmed before teardown, so `test:visual` runs the whole project (#1384) diff --git a/app/src/components/__tests__/card-container-states.test.tsx b/app/src/components/__tests__/card-container-states.test.tsx index 82b76c07..faf81733 100644 --- a/app/src/components/__tests__/card-container-states.test.tsx +++ b/app/src/components/__tests__/card-container-states.test.tsx @@ -98,6 +98,28 @@ vi.mock("@/lib/query/data-transforms", () => ({ applyTransforms: (d: unknown) => d, })); +// `chart-helpers` registers *lightweight stub* plugins when the full plugin +// modules haven't loaded, and a stub carries no `validate` — so without this +// the validation path is unreachable from jsdom and #1400 could not be tested +// at this layer at all. Attach the real bar validator; importing the plugin +// module itself would drag ECharts into jsdom for no benefit. +vi.mock("@/lib/plugin/chart-helpers", async () => { + const actual = await vi.importActual< + typeof import("@/lib/plugin/chart-helpers") + >("@/lib/plugin/chart-helpers"); + const { validateBarData } = await vi.importActual< + typeof import("@/plugins/bar/transform") + >("@/plugins/bar/transform"); + return { + ...actual, + getChartConfig: (type: string) => { + const config = actual.getChartConfig(type); + if (!config || type !== "bar") return config; + return { ...config, validate: validateBarData }; + }, + }; +}); + /* ---------- import under test ---------- */ import { CardContainer } from "../card-container"; import type { DashboardWidget } from "@/lib/db/schema"; @@ -462,4 +484,63 @@ describe("CardContainer", () => { expect(screen.queryByText(/Showing first .* rows/)).toBeNull(); }); + + // ----- Long-format rejection (#1400) ----- + + describe("long-format results (#1400)", () => { + const longFormat = [ + { category: "Apparel", series: "delivered", revenue: 100 }, + { category: "Apparel", series: "shipped", revenue: 50 }, + { category: "Home", series: "delivered", revenue: 80 }, + ]; + + it("renders an explicit error state instead of a silently wrong chart", () => { + mockUseWidgetQuery.mockReturnValue({ + isPending: false, + fetchStatus: "idle", + isError: false, + data: { data: longFormat, resultId: "r1" }, + missingParams: [], + }); + + render(); + + expect(screen.queryByTestId("chart-renderer")).toBeNull(); + expect(screen.getByText("Incompatible data format")).toBeDefined(); + }); + + it("names the offending column in the error", () => { + mockUseWidgetQuery.mockReturnValue({ + isPending: false, + fetchStatus: "idle", + isError: false, + data: { data: longFormat, resultId: "r1" }, + missingParams: [], + }); + + render(); + + expect(screen.getByText(/"series"/)).toBeDefined(); + }); + + it("still renders the chart for the wide-format equivalent", () => { + mockUseWidgetQuery.mockReturnValue({ + isPending: false, + fetchStatus: "idle", + isError: false, + data: { + data: [ + { category: "Apparel", delivered: 100, shipped: 50 }, + { category: "Home", delivered: 80, shipped: 20 }, + ], + resultId: "r1", + }, + missingParams: [], + }); + + render(); + + expect(screen.getByTestId("chart-renderer")).toBeDefined(); + }); + }); }); diff --git a/app/src/components/card-container.tsx b/app/src/components/card-container.tsx index 673fcc34..ac936f3f 100644 --- a/app/src/components/card-container.tsx +++ b/app/src/components/card-container.tsx @@ -276,8 +276,7 @@ export function CardContainer({ // Resolve color scales config const conditionalFormatting = ws.conditionalFormatting as - | { colorScales?: ColorScaleConfig[] } - | undefined; + { colorScales?: ColorScaleConfig[] } | undefined; const colorScales = conditionalFormatting?.colorScales; if (!chartConfig) { @@ -293,7 +292,8 @@ export function CardContainer({ // Use preview data directly if provided if (previewData !== undefined) { - const validationError = chartConfig.validate?.(previewData) ?? null; + const validationError = + chartConfig.validate?.(previewData, columnMapping) ?? null; if (validationError) { return ( string | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ColumnMapping lives in component package + validate?: (data: unknown, mapping?: any) => string | null; /** Chart-specific options shown in the Chart Options panel. */ options?: ChartOptionDef[]; /** Example + column expectations shown to users when they pick this chart. */ diff --git a/app/src/plugins/bar/transform.ts b/app/src/plugins/bar/transform.ts index e0b50603..d80b8639 100644 --- a/app/src/plugins/bar/transform.ts +++ b/app/src/plugins/bar/transform.ts @@ -9,6 +9,7 @@ import { normalizeValue, collectAllKeys, toSeriesNumber, + validateNumericValueColumns, type ColumnMapping, } from "../transforms/shared-utils"; @@ -48,11 +49,14 @@ export function transformToBarData( * Validates raw data shape for bar charts. * Returns null if valid or empty, error string if rows exist but shape is wrong. */ -export function validateBarData(data: unknown): string | null { +export function validateBarData( + data: unknown, + mapping?: ColumnMapping, +): string | null { const records = toRecords(data); if (!records.length) return null; const cols = collectAllKeys(records).length; if (cols < 2) return `Bar chart requires at least 2 columns: first column for category labels (x-axis) and one or more columns for numeric values (y-axis). Your query returned only ${cols} column(s). Example: \`SELECT category, count FROM ...\``; - return null; + return validateNumericValueColumns(records, "Bar chart", mapping); } diff --git a/app/src/plugins/line/transform.ts b/app/src/plugins/line/transform.ts index 6c007203..ae8d6aa5 100644 --- a/app/src/plugins/line/transform.ts +++ b/app/src/plugins/line/transform.ts @@ -9,6 +9,7 @@ import { normalizeValue, collectAllKeys, toSeriesNumber, + validateNumericValueColumns, type ColumnMapping, } from "../transforms/shared-utils"; @@ -47,11 +48,14 @@ export function transformToLineData( * Validates raw data shape for line charts. * Returns null if valid or empty, error string if rows exist but shape is wrong. */ -export function validateLineData(data: unknown): string | null { +export function validateLineData( + data: unknown, + mapping?: ColumnMapping, +): string | null { const records = toRecords(data); if (!records.length) return null; const cols = collectAllKeys(records).length; if (cols < 2) return `Line chart requires at least 2 columns: first column for x-axis values (dates, numbers, or labels) and one or more columns for numeric series. Your query returned only ${cols} column(s). Example: \`SELECT date, revenue FROM ...\``; - return null; + return validateNumericValueColumns(records, "Line chart", mapping); } diff --git a/app/src/plugins/transforms/__tests__/bar.test.ts b/app/src/plugins/transforms/__tests__/bar.test.ts index 9d432a7a..25a6c33e 100644 --- a/app/src/plugins/transforms/__tests__/bar.test.ts +++ b/app/src/plugins/transforms/__tests__/bar.test.ts @@ -107,3 +107,34 @@ describe("validateBarData", () => { expect(err).toContain("1 column"); }); }); + +// #1400 — the reference dashboard itself demonstrated two stackMode options +// with this query shape, which is how easy it is to fall into. +describe("validateBarData — long format (#1400)", () => { + const longFormat = [ + { category: "Apparel", series: "delivered", revenue: 100 }, + { category: "Apparel", series: "shipped", revenue: 50 }, + { category: "Home", series: "delivered", revenue: 80 }, + ]; + + it("rejects a long-format result naming the offending column", () => { + const err = validateBarData(longFormat); + expect(err).not.toBeNull(); + expect(err).toContain("series"); + }); + + it("accepts the wide-format equivalent", () => { + expect( + validateBarData([ + { category: "Apparel", delivered: 100, shipped: 50 }, + { category: "Home", delivered: 80, shipped: 20 }, + ]), + ).toBeNull(); + }); + + it("accepts long format once the value column is mapped explicitly", () => { + expect( + validateBarData(longFormat, { xAxis: "category", yAxis: ["revenue"] }), + ).toBeNull(); + }); +}); diff --git a/app/src/plugins/transforms/__tests__/line.test.ts b/app/src/plugins/transforms/__tests__/line.test.ts index 497ee203..24840de1 100644 --- a/app/src/plugins/transforms/__tests__/line.test.ts +++ b/app/src/plugins/transforms/__tests__/line.test.ts @@ -94,3 +94,35 @@ describe("validateLineData", () => { expect(err).toContain("Line chart"); }); }); + +// #1400 — line was the worse of the two: one revenue line drawn across +// duplicated x values, interleaving delivered -> shipped -> delivered, which +// reads as a violently spiky time series but is pure artifact. +describe("validateLineData — long format (#1400)", () => { + const longFormat = [ + { week: "2026-01-05", series: "delivered", revenue: 100 }, + { week: "2026-01-05", series: "shipped", revenue: 50 }, + { week: "2026-01-12", series: "delivered", revenue: 80 }, + ]; + + it("rejects a long-format result naming the offending column", () => { + const err = validateLineData(longFormat); + expect(err).not.toBeNull(); + expect(err).toContain("series"); + }); + + it("accepts the wide-format equivalent", () => { + expect( + validateLineData([ + { week: "2026-01-05", delivered: 100, shipped: 50 }, + { week: "2026-01-12", delivered: 80, shipped: 20 }, + ]), + ).toBeNull(); + }); + + it("accepts long format once the value column is mapped explicitly", () => { + expect( + validateLineData(longFormat, { xAxis: "week", yAxis: ["revenue"] }), + ).toBeNull(); + }); +}); diff --git a/app/src/plugins/transforms/__tests__/shared.test.ts b/app/src/plugins/transforms/__tests__/shared.test.ts index 20a2d1d0..2f31f546 100644 --- a/app/src/plugins/transforms/__tests__/shared.test.ts +++ b/app/src/plugins/transforms/__tests__/shared.test.ts @@ -5,6 +5,7 @@ import { resolveValueKeys, collectAllKeys, toSeriesNumber, + validateNumericValueColumns, } from "../shared-utils"; describe("toRecords", () => { @@ -126,3 +127,96 @@ describe("toSeriesNumber", () => { expect(toSeriesNumber(Number.NaN)).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// #1400 — long-format rejection +// --------------------------------------------------------------------------- + +describe("validateNumericValueColumns (#1400)", () => { + // The natural shape of `GROUP BY a, b`. Every non-label column was treated + // as a value series, so `series` became a series of its own whose cells all + // coerced to null — a ghost legend entry, duplicated x labels, and a chart + // that looked like data. + const longFormat = [ + { category: "Apparel", series: "delivered", revenue: 100 }, + { category: "Apparel", series: "shipped", revenue: 50 }, + { category: "Home", series: "delivered", revenue: 80 }, + ]; + + it("rejects a value column whose every non-null cell is non-numeric", () => { + const msg = validateNumericValueColumns(longFormat, "Bar chart"); + expect(msg).not.toBeNull(); + }); + + it("names the offending column", () => { + const msg = validateNumericValueColumns(longFormat, "Bar chart"); + expect(msg).toContain("series"); + }); + + it("accepts a wide-format result", () => { + const wide = [ + { category: "Apparel", delivered: 100, shipped: 50 }, + { category: "Home", delivered: 80, shipped: 20 }, + ]; + expect(validateNumericValueColumns(wide, "Bar chart")).toBeNull(); + }); + + it("accepts numeric strings", () => { + const rows = [ + { month: "Jan", revenue: "100" }, + { month: "Feb", revenue: "200" }, + ]; + expect(validateNumericValueColumns(rows, "Line chart")).toBeNull(); + }); + + // An all-null column is a legitimate sparse series — what a LEFT JOIN + // produces — and `collectAllKeys` exists specifically to keep it. Rejecting + // it would re-break the case the union-of-keys logic was written for. + it("accepts an entirely null column as a sparse series", () => { + const sparse = [ + { month: "Jan", revenue: 100, forecast: null }, + { month: "Feb", revenue: 200, forecast: null }, + ]; + expect(validateNumericValueColumns(sparse, "Line chart")).toBeNull(); + }); + + it("accepts a column that is only partly populated", () => { + const partial = [ + { month: "Jan", revenue: 100, forecast: null }, + { month: "Feb", revenue: 200, forecast: 250 }, + ]; + expect(validateNumericValueColumns(partial, "Line chart")).toBeNull(); + }); + + it("respects an explicit yAxis mapping and ignores unmapped text columns", () => { + // The user mapped the value column themselves; `series` is not plotted, + // so it must not be flagged. + const msg = validateNumericValueColumns(longFormat, "Bar chart", { + xAxis: "category", + yAxis: ["revenue"], + }); + expect(msg).toBeNull(); + }); + + it("flags an explicitly mapped column that is non-numeric", () => { + const msg = validateNumericValueColumns(longFormat, "Bar chart", { + xAxis: "category", + yAxis: ["series"], + }); + expect(msg).toContain("series"); + }); + + it("names every offending column when there is more than one", () => { + const rows = [ + { category: "A", series: "x", label: "p", revenue: 1 }, + { category: "B", series: "y", label: "q", revenue: 2 }, + ]; + const msg = validateNumericValueColumns(rows, "Bar chart") ?? ""; + expect(msg).toContain("series"); + expect(msg).toContain("label"); + }); + + it("returns null for empty data", () => { + expect(validateNumericValueColumns([], "Bar chart")).toBeNull(); + }); +}); diff --git a/app/src/plugins/transforms/shared-utils.ts b/app/src/plugins/transforms/shared-utils.ts index aace4de9..4ce14cc3 100644 --- a/app/src/plugins/transforms/shared-utils.ts +++ b/app/src/plugins/transforms/shared-utils.ts @@ -81,3 +81,51 @@ export function toSeriesNumber(raw: unknown): number | null { const n = typeof raw === "number" ? raw : Number(raw); return Number.isFinite(n) ? n : null; } + +/** + * Reject a result whose plotted value columns hold no numbers at all (#1400). + * + * A long-format result — `category, series, value`, what `GROUP BY a, b` + * naturally produces — used to render cleanly and wrongly: `resolveValueKeys` + * treats every non-label column as a series, so `series` became a series of + * its own whose cells all coerced to null. That produced duplicated category + * labels, a ghost legend entry with no bars, and (on line) a single value + * line zig-zagging across repeated x values. None of it looked like an error. + * + * Resolution goes through `resolveValueKeys` — the same call the transform + * makes — so the validator cannot disagree with what is actually plotted. + * + * A column is only rejected when it has at least one non-null cell and *none* + * of them parse. An entirely null column stays legal: that is a sparse series, + * what a LEFT JOIN produces, and the reason `collectAllKeys` unions keys. + */ +export function validateNumericValueColumns( + records: Record[], + chartLabel: string, + mapping?: ColumnMapping, +): string | null { + if (!records.length) return null; + const keys = collectAllKeys(records); + // Fewer than 2 columns is the callers' own column-count error, which says + // something more useful than this would. + if (keys.length < 2) return null; + + const labelKey = resolveLabelKey(keys, mapping); + const offenders = resolveValueKeys(keys, labelKey, mapping).filter((key) => { + let sawValue = false; + for (const record of records) { + const raw = record[key]; + if (raw === null || raw === undefined) continue; + if (typeof raw === "string" && raw.trim() === "") continue; + sawValue = true; + if (toSeriesNumber(raw) !== null) return false; + } + return sawValue; + }); + + if (!offenders.length) return null; + + const named = offenders.map((k) => `"${k}"`).join(", "); + const plural = offenders.length > 1; + return `${chartLabel} cannot plot ${named} — ${plural ? "those columns contain" : "that column contains"} no numeric values. This usually means the result is in long format (one row per category *and* series, e.g. \`GROUP BY category, status\`), where the series name is its own column. Pivot it so each series is a column, or map the value column explicitly. Example: \`SELECT category, SUM(x) FILTER (WHERE status='delivered') AS delivered, SUM(x) FILTER (WHERE status='shipped') AS shipped FROM ... GROUP BY category\`.`; +} diff --git a/scripts/demo/chart-reference.json b/scripts/demo/chart-reference.json index 3ac537c8..b4e44095 100644 --- a/scripts/demo/chart-reference.json +++ b/scripts/demo/chart-reference.json @@ -64,7 +64,7 @@ "id": "page-bar-v-4", "chartType": "bar", "connectionId": "conn_postgres_read", - "query": "SELECT parent.name AS category, o.status AS series, SUM(oi.qty * oi.price)::float AS revenue FROM neoboard_demo_public.order_items oi JOIN neoboard_demo_public.products p ON p.id = oi.product_id JOIN neoboard_demo_public.categories sub ON sub.id = p.category_id JOIN neoboard_demo_public.categories parent ON parent.id = sub.parent_id JOIN neoboard_demo_public.orders o ON o.id = oi.order_id WHERE o.status IN ('delivered','shipped','pending') GROUP BY parent.name, o.status ORDER BY parent.name", + "query": "SELECT parent.name AS category, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'delivered')::float AS delivered, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'shipped')::float AS shipped, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'pending')::float AS pending FROM neoboard_demo_public.order_items oi JOIN neoboard_demo_public.products p ON p.id = oi.product_id JOIN neoboard_demo_public.categories sub ON sub.id = p.category_id JOIN neoboard_demo_public.categories parent ON parent.id = sub.parent_id JOIN neoboard_demo_public.orders o ON o.id = oi.order_id WHERE o.status IN ('delivered','shipped','pending') GROUP BY parent.name ORDER BY parent.name", "settings": { "title": "stackMode = stacked", "chartOptions": { @@ -76,7 +76,7 @@ "id": "page-bar-v-5", "chartType": "bar", "connectionId": "conn_postgres_read", - "query": "SELECT parent.name AS category, o.status AS series, SUM(oi.qty * oi.price)::float AS revenue FROM neoboard_demo_public.order_items oi JOIN neoboard_demo_public.products p ON p.id = oi.product_id JOIN neoboard_demo_public.categories sub ON sub.id = p.category_id JOIN neoboard_demo_public.categories parent ON parent.id = sub.parent_id JOIN neoboard_demo_public.orders o ON o.id = oi.order_id WHERE o.status IN ('delivered','shipped','pending') GROUP BY parent.name, o.status ORDER BY parent.name", + "query": "SELECT parent.name AS category, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'delivered')::float AS delivered, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'shipped')::float AS shipped, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'pending')::float AS pending FROM neoboard_demo_public.order_items oi JOIN neoboard_demo_public.products p ON p.id = oi.product_id JOIN neoboard_demo_public.categories sub ON sub.id = p.category_id JOIN neoboard_demo_public.categories parent ON parent.id = sub.parent_id JOIN neoboard_demo_public.orders o ON o.id = oi.order_id WHERE o.status IN ('delivered','shipped','pending') GROUP BY parent.name ORDER BY parent.name", "settings": { "title": "stackMode = percent", "chartOptions": { @@ -100,7 +100,7 @@ "id": "page-bar-v-7", "chartType": "bar", "connectionId": "conn_postgres_read", - "query": "SELECT parent.name AS category, o.status AS series, SUM(oi.qty * oi.price)::float AS revenue FROM neoboard_demo_public.order_items oi JOIN neoboard_demo_public.products p ON p.id = oi.product_id JOIN neoboard_demo_public.categories sub ON sub.id = p.category_id JOIN neoboard_demo_public.categories parent ON parent.id = sub.parent_id JOIN neoboard_demo_public.orders o ON o.id = oi.order_id WHERE o.status IN ('delivered','shipped','pending') GROUP BY parent.name, o.status ORDER BY parent.name", + "query": "SELECT parent.name AS category, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'delivered')::float AS delivered, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'shipped')::float AS shipped, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'pending')::float AS pending FROM neoboard_demo_public.order_items oi JOIN neoboard_demo_public.products p ON p.id = oi.product_id JOIN neoboard_demo_public.categories sub ON sub.id = p.category_id JOIN neoboard_demo_public.categories parent ON parent.id = sub.parent_id JOIN neoboard_demo_public.orders o ON o.id = oi.order_id WHERE o.status IN ('delivered','shipped','pending') GROUP BY parent.name ORDER BY parent.name", "settings": { "title": "showLegend = false", "chartOptions": { @@ -125,7 +125,7 @@ "id": "page-bar-v-9", "chartType": "bar", "connectionId": "conn_postgres_read", - "query": "SELECT parent.name AS category, o.status AS series, SUM(oi.qty * oi.price)::float AS revenue FROM neoboard_demo_public.order_items oi JOIN neoboard_demo_public.products p ON p.id = oi.product_id JOIN neoboard_demo_public.categories sub ON sub.id = p.category_id JOIN neoboard_demo_public.categories parent ON parent.id = sub.parent_id JOIN neoboard_demo_public.orders o ON o.id = oi.order_id WHERE o.status IN ('delivered','shipped','pending') GROUP BY parent.name, o.status ORDER BY parent.name", + "query": "SELECT parent.name AS category, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'delivered')::float AS delivered, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'shipped')::float AS shipped, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'pending')::float AS pending FROM neoboard_demo_public.order_items oi JOIN neoboard_demo_public.products p ON p.id = oi.product_id JOIN neoboard_demo_public.categories sub ON sub.id = p.category_id JOIN neoboard_demo_public.categories parent ON parent.id = sub.parent_id JOIN neoboard_demo_public.orders o ON o.id = oi.order_id WHERE o.status IN ('delivered','shipped','pending') GROUP BY parent.name ORDER BY parent.name", "settings": { "title": "barGap = 80%", "chartOptions": { @@ -214,7 +214,7 @@ "id": "page-bar-v-16", "chartType": "bar", "connectionId": "conn_postgres_read", - "query": "SELECT parent.name AS category, o.status AS series, SUM(oi.qty * oi.price)::float AS revenue FROM neoboard_demo_public.order_items oi JOIN neoboard_demo_public.products p ON p.id = oi.product_id JOIN neoboard_demo_public.categories sub ON sub.id = p.category_id JOIN neoboard_demo_public.categories parent ON parent.id = sub.parent_id JOIN neoboard_demo_public.orders o ON o.id = oi.order_id WHERE o.status IN ('delivered','shipped','pending') GROUP BY parent.name, o.status ORDER BY parent.name", + "query": "SELECT parent.name AS category, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'delivered')::float AS delivered, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'shipped')::float AS shipped, SUM(oi.qty * oi.price) FILTER (WHERE o.status = 'pending')::float AS pending FROM neoboard_demo_public.order_items oi JOIN neoboard_demo_public.products p ON p.id = oi.product_id JOIN neoboard_demo_public.categories sub ON sub.id = p.category_id JOIN neoboard_demo_public.categories parent ON parent.id = sub.parent_id JOIN neoboard_demo_public.orders o ON o.id = oi.order_id WHERE o.status IN ('delivered','shipped','pending') GROUP BY parent.name ORDER BY parent.name", "settings": { "title": "colorblindMode = true", "chartOptions": { @@ -489,7 +489,7 @@ "id": "page-line-v-26", "chartType": "line", "connectionId": "conn_postgres_read", - "query": "SELECT to_char(date_trunc('week', created_at), 'YYYY-MM-DD') AS week, status AS series, SUM(total)::float AS revenue FROM neoboard_demo_public.orders WHERE status IN ('delivered','shipped') GROUP BY 1, status ORDER BY 1 LIMIT 24", + "query": "SELECT to_char(date_trunc('week', created_at), 'YYYY-MM-DD') AS week, SUM(total) FILTER (WHERE status = 'delivered')::float AS delivered, SUM(total) FILTER (WHERE status = 'shipped')::float AS shipped FROM neoboard_demo_public.orders WHERE status IN ('delivered','shipped') GROUP BY 1 ORDER BY 1 LIMIT 24", "settings": { "title": "endLabel = true (multi-series)", "chartOptions": { @@ -514,7 +514,7 @@ "id": "page-line-v-28", "chartType": "line", "connectionId": "conn_postgres_read", - "query": "SELECT to_char(date_trunc('week', created_at), 'YYYY-MM-DD') AS week, status AS series, SUM(total)::float AS revenue FROM neoboard_demo_public.orders WHERE status IN ('delivered','shipped') GROUP BY 1, status ORDER BY 1 LIMIT 24", + "query": "SELECT to_char(date_trunc('week', created_at), 'YYYY-MM-DD') AS week, SUM(total) FILTER (WHERE status = 'delivered')::float AS delivered, SUM(total) FILTER (WHERE status = 'shipped')::float AS shipped FROM neoboard_demo_public.orders WHERE status IN ('delivered','shipped') GROUP BY 1 ORDER BY 1 LIMIT 24", "settings": { "title": "right Y-axis series", "chartOptions": { @@ -527,7 +527,7 @@ "id": "page-line-v-29", "chartType": "line", "connectionId": "conn_postgres_read", - "query": "SELECT to_char(date_trunc('week', created_at), 'YYYY-MM-DD') AS week, status AS series, SUM(total)::float AS revenue FROM neoboard_demo_public.orders WHERE status IN ('delivered','shipped') GROUP BY 1, status ORDER BY 1 LIMIT 24", + "query": "SELECT to_char(date_trunc('week', created_at), 'YYYY-MM-DD') AS week, SUM(total) FILTER (WHERE status = 'delivered')::float AS delivered, SUM(total) FILTER (WHERE status = 'shipped')::float AS shipped FROM neoboard_demo_public.orders WHERE status IN ('delivered','shipped') GROUP BY 1 ORDER BY 1 LIMIT 24", "settings": { "title": "showLegend = false", "chartOptions": { @@ -589,7 +589,7 @@ "id": "page-line-v-34", "chartType": "line", "connectionId": "conn_postgres_read", - "query": "SELECT to_char(date_trunc('week', created_at), 'YYYY-MM-DD') AS week, status AS series, SUM(total)::float AS revenue FROM neoboard_demo_public.orders WHERE status IN ('delivered','shipped') GROUP BY 1, status ORDER BY 1 LIMIT 24", + "query": "SELECT to_char(date_trunc('week', created_at), 'YYYY-MM-DD') AS week, SUM(total) FILTER (WHERE status = 'delivered')::float AS delivered, SUM(total) FILTER (WHERE status = 'shipped')::float AS shipped FROM neoboard_demo_public.orders WHERE status IN ('delivered','shipped') GROUP BY 1 ORDER BY 1 LIMIT 24", "settings": { "title": "palette = warm", "chartOptions": { @@ -601,7 +601,7 @@ "id": "page-line-v-35", "chartType": "line", "connectionId": "conn_postgres_read", - "query": "SELECT to_char(date_trunc('week', created_at), 'YYYY-MM-DD') AS week, status AS series, SUM(total)::float AS revenue FROM neoboard_demo_public.orders WHERE status IN ('delivered','shipped') GROUP BY 1, status ORDER BY 1 LIMIT 24", + "query": "SELECT to_char(date_trunc('week', created_at), 'YYYY-MM-DD') AS week, SUM(total) FILTER (WHERE status = 'delivered')::float AS delivered, SUM(total) FILTER (WHERE status = 'shipped')::float AS shipped FROM neoboard_demo_public.orders WHERE status IN ('delivered','shipped') GROUP BY 1 ORDER BY 1 LIMIT 24", "settings": { "title": "palette = observable", "chartOptions": { @@ -613,7 +613,7 @@ "id": "page-line-v-36", "chartType": "line", "connectionId": "conn_postgres_read", - "query": "SELECT to_char(date_trunc('week', created_at), 'YYYY-MM-DD') AS week, status AS series, SUM(total)::float AS revenue FROM neoboard_demo_public.orders WHERE status IN ('delivered','shipped') GROUP BY 1, status ORDER BY 1 LIMIT 24", + "query": "SELECT to_char(date_trunc('week', created_at), 'YYYY-MM-DD') AS week, SUM(total) FILTER (WHERE status = 'delivered')::float AS delivered, SUM(total) FILTER (WHERE status = 'shipped')::float AS shipped FROM neoboard_demo_public.orders WHERE status IN ('delivered','shipped') GROUP BY 1 ORDER BY 1 LIMIT 24", "settings": { "title": "colorblindMode = true", "chartOptions": {