diff --git a/CHANGELOG.md b/CHANGELOG.md index fdbc160b..3e8c7fab 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) - `numberFormat: "percent"` appended a `%` sign and scaled nothing, so a KPI computing `401 / 2000` rendered **`0.2%`** where the true share was **`20.05%`** — wrong by a factor of 100, rendered cleanly, with nothing in the UI to distinguish it from a real value. `0.2%` and `20.05%` are both believable share figures. The convention was never stated anywhere and the two option surfaces documented **opposite** contracts: the table's option was labelled `Percent (12%)`, asserting input was already 0–100, while the seeded reference tile was authored with ratio semantics — the clearest evidence the contract was not discoverable. `percent` now takes a **ratio** and scales it, via `Intl.NumberFormat`'s own `style: "percent"`, which also brings locale grouping (`12.5` → `1,250%`) that the hand-rolled branch never had. With no explicit decimal places it caps at 2 rather than Intl's default of 0, since rounding `20.05%` to `20%` is the same class of silent precision loss. **Breaking** for anyone who worked around the old behaviour by pre-multiplying by 100 in their query. Both option descriptions, both option labels, the single-value docs page and the demo tile now state the expected input range (#1396) - A client-side `groupBy` reported `count` as the number of **rows** in the group while `sum`, `avg`, `min` and `max` all skipped nulls, so the three contradicted each other: a group of `[100, 200, null]` returned `count: 3, sum: 300, avg: 150`, and `sum / count` came to 100. A reader saw "3 revenue values totalling 300" with no way to know only two rows had revenue at all — and because `count` is typically the denominator someone divides by, the error propagated into everything derived from it. Nulls in an aggregated column are not an edge case; they are what a `LEFT JOIN` produces. `count` now counts non-null values, matching SQL's `COUNT(col)` — the output key is `revenue_count`, named for the column, so the column form is the only one it could have meant. The existing test used sample data with no nulls and passed on both the wrong and the right behaviour; the new invariant test (`sum / count === avg` for every group) fails on the old code and cannot be silently re-broken (#1414) - 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) 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 d8bf7e4c..830768b9 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": {