From 453c7f6fb598897e330bd09676c37433d043157c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:36:51 +0000 Subject: [PATCH 1/7] fix(plugin-dashboard): a compareTo tile shows its number under All time When the resolved filter dates nothing on the compared field (the reader picked All time on the dashboard date filter), DatasetWidget asks for no comparison instead of sending compareTo with no window for the executor to refuse. An author-written one-bound window still reaches the executor and keeps its loud refusal. Claude-Session: https://claude.ai/code/session_01RJW3o457EFFwmLN1429vn9 Co-authored-by: Claude --- .changeset/25970-compare-to-all-time.md | 12 ++ ui/packages/plugin-dashboard/SKILL.md | 10 +- .../plugin-dashboard/src/DatasetWidget.tsx | 60 +++++++++- ...rdRenderer.compareToAllTime-25970.test.tsx | 96 ++++++++++++++++ .../DatasetWidget.compareTo.test.tsx | 108 +++++++++++++++++- 5 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 .changeset/25970-compare-to-all-time.md create mode 100644 ui/packages/plugin-dashboard/src/__tests__/DashboardRenderer.compareToAllTime-25970.test.tsx diff --git a/.changeset/25970-compare-to-all-time.md b/.changeset/25970-compare-to-all-time.md new file mode 100644 index 00000000000..11260361f37 --- /dev/null +++ b/.changeset/25970-compare-to-all-time.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-dashboard': patch +--- + +fix(plugin-dashboard): a `compareTo` KPI tile shows its number when the reader picks All time, instead of the executor's "compareTo needs a dated window to shift" error + +Clause-②: no +Metadata-protocol: none + +- **Before.** A dataset-bound widget with `compareTo` always sent `compareTo` to the dataset executor, but lowered a `timeDimensions` window only when its resolved filter held a closed `{ $gte, $lte }` date pair. Picking **All time** on the dashboard's date-range filter removes that pair, so the executor refused the query and the tile showed a 400 error where its number belonged. The same tile without `compareTo` showed its number. +- **Now.** When the resolved filter carries no date condition at all on the compared field (on any field when `compareTo.dimension` is omitted), the widget asks for no comparison: the query is the one a widget without `compareTo` sends, and the tile shows its number with no comparison badge. +- **Unchanged: an author-written window that is not closed stays an error.** A one-bound window (`{ $gte: '{current_year_start}' }`, a YTD tile), an exclusive bound or a single day is still sent with `compareTo`, and the executor still refuses it and names the fix. The executor and the spec define a comparison only against a two-bound window. Two dated fields with no `compareTo.dimension` stay the executor's ambiguity error. diff --git a/ui/packages/plugin-dashboard/SKILL.md b/ui/packages/plugin-dashboard/SKILL.md index dced1324057..09d8f64049f 100644 --- a/ui/packages/plugin-dashboard/SKILL.md +++ b/ui/packages/plugin-dashboard/SKILL.md @@ -162,10 +162,14 @@ running over a window nobody asked for. ### When NOT to use `compareTo` -- Filters with no bounded date range — there is no window to shift, and the - widget reports that instead of rendering a comparison. Date the widget (a +- Filters whose date condition is not a closed `$gte`/`$lte` window — a + one-bound YTD `{ $gte: '{current_year_start}' }`, an exclusive `$lt` bound, + a single day. There is no window to shift, and the widget reports that + instead of rendering a comparison. Date the widget with a closed pair (a date-macro `$gte`/`$lte` pair, or a dashboard date-range filter bound to it) - or omit `compareTo` entirely. + or omit `compareTo` entirely. A filter that dates **nothing** on the compared + field — the reader picked All time on the dashboard date filter — is not an + error: the widget asks for no comparison and shows its number with no badge. - Pie / donut / funnel charts — comparison overlays are not visually meaningful and are silently ignored. - Scatter charts, and the `bubble` widget type that renders as one — a scatter diff --git a/ui/packages/plugin-dashboard/src/DatasetWidget.tsx b/ui/packages/plugin-dashboard/src/DatasetWidget.tsx index 6fcccad0093..f5ce131ca4e 100644 --- a/ui/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/ui/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -323,7 +323,9 @@ function boundedDateWindow(value: unknown): [string, string] | null { * lowered under the name the author wrote. Zero of them, or two, is the * executor's error to raise — it names the candidates (`resolveCompareDimension`). * Guessing one renderer-side would trade a loud error for a quietly wrong - * window, which is the failure class objectstack#5011 set out to end. + * window, which is the failure class objectstack#5011 set out to end. The one + * zero-window filter that never reaches the executor is a filter that dates + * nothing at all — see `filterDatesField`, which the widget asks first. */ export function extractDateWindows(filter: unknown): { windows: DatasetTimeWindow[]; @@ -390,6 +392,46 @@ export function extractDateWindows(filter: unknown): { }; } +/** + * Does a RESOLVED runtime filter state any date condition on `dimension` — or, + * with no dimension named, on any field at all? (objectstack#25970) + * + * A comparison needs a window, and a filter can lack one for two different + * reasons. The AUTHOR wrote a date condition that is not a closed window (a + * one-bound YTD `{ $gte }`, an exclusive bound, a single day): that is a + * metadata mistake, and it stays the executor's loud refusal — the executor + * and the spec define a comparison only against a two-bound window. Or the + * filter dates nothing at all, which is what a READER produces by picking All + * time on the dashboard's date-range filter: `DashboardRenderer` then merges no + * condition for it, and the author cannot prevent the choice. With no window + * there is no earlier period to compare against, so that widget asks for no + * comparison and shows its number, instead of a normal filter choice breaking + * the tile. + * + * "A date condition" is any ISO-date operand under the field, at any depth and + * in any position (`$or` / `$not` included) — deliberately wider than the + * windows `extractDateWindows` lowers, so every author-written date shape still + * reaches the executor and is told why it cannot be compared. + */ +export function filterDatesField(filter: unknown, dimension?: string): boolean { + const datesValue = (value: unknown): boolean => { + if (typeof value === 'string') return ISO_DATE_RE.test(value); + if (Array.isArray(value)) return value.some(datesValue); + if (value && typeof value === 'object') return Object.values(value as object).some(datesValue); + return false; + }; + const walk = (node: unknown): boolean => { + if (Array.isArray(node)) return node.some(walk); + if (!node || typeof node !== 'object') return false; + return Object.entries(node as Record).some(([key, value]) => + key.startsWith('$') + ? walk(value) + : (dimension == null || key === dimension) && datesValue(value), + ); + }; + return walk(filter); +} + /** Single-value KPI widget types — rendered as a number, not a chart. */ const METRIC_TYPES = new Set(['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet']); @@ -694,7 +736,11 @@ export function DatasetWidget({ widget, dataSource, heading }: { widget: any; da // overlay the renderer then dropped. Gated on the CHART branch, not on the // widget type: a pie widget with no dimensions renders as a metric tile // (`isMetric`), which does show the comparison as a delta, so it keeps it. - const compareTo: CompareToConfig | undefined = + // + // This is the comparison the widget DECLARES for what it renders. Whether it + // is asked for also depends on the resolved filter (a reader's All time dates + // nothing), so the query and the overlay read `compareTo`, settled below. + const declaredCompareTo: CompareToConfig | undefined = !isMetric && !isTable && chartTypeIgnoresCompareTo(chartType) ? undefined : widget?.compareTo; // pivot with ≥2 dims → a true cross-tab: last dim spreads across as columns, // the rest go down as rows. Computed up-front so the fetch can also request @@ -805,6 +851,16 @@ export function DatasetWidget({ widget, dataSource, heading }: { widget: any; da // widget's query byte-identical keeps this change to the path it is about. // See `extractDateWindows` for why the windows are MOVED and why the choice // of dimension is left to the executor. + // + // A filter that dates nothing on the compared field — the dashboard's date + // filter set to All time, with no date in the widget's own filter — asks for + // no comparison at all: no `compareTo`, no window lowered, the same query and + // tile as a widget without `compareTo` (objectstack#25970). Any date + // condition the author wrote, a one-bound `{ $gte }` included, still goes to + // the executor, whose refusal names the fix (`filterDatesField`). + const compareTo = declaredCompareTo && filterDatesField(runtimeFilter, declaredCompareTo.dimension) + ? declaredCompareTo + : undefined; const { windows: compareWindows, rest: selectionFilter } = useMemo( () => (compareTo ? extractDateWindows(runtimeFilter) diff --git a/ui/packages/plugin-dashboard/src/__tests__/DashboardRenderer.compareToAllTime-25970.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DashboardRenderer.compareToAllTime-25970.test.tsx new file mode 100644 index 00000000000..6cf52766e83 --- /dev/null +++ b/ui/packages/plugin-dashboard/src/__tests__/DashboardRenderer.compareToAllTime-25970.test.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#25970 — a `compareTo` KPI tile on a dashboard whose reader picks + * All time in the date filter shows its number, with no comparison badge and + * no error. + * + * All time is a supported filter choice the author cannot prevent: the filter + * bar stores no value, `DashboardRenderer` merges no condition, and the + * widget's resolved filter dates nothing. The tile used to send `compareTo` + * anyway and render the executor's "compareTo needs a dated window to shift" + * refusal in place of its number. A window the AUTHOR wrote as one bound (YTD) + * keeps that refusal: the executor and the spec define a comparison only + * against a two-bound window. + * + * Drives the real Radix select with user-event; the data source answers the + * way the executor does (`resolveCompareDimension`). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { DashboardComponentSchema } from '@object-ui/types'; +import { DashboardRenderer } from '../DashboardRenderer'; + +afterEach(cleanup); + +const REFUSAL = + 'Dataset query failed: 400 Bad Request — [dataset-executor] compareTo needs a dated window to shift'; + +const makeQueryDataset = () => + vi.fn(async (_dataset: string, selection: any) => { + const shiftable = (selection.timeDimensions ?? []).some((t: any) => t.dateRange); + if (selection.compareTo && !shiftable) throw new Error(REFUSAL); + return { + rows: [selection.compareTo ? { revenue: 120, revenue__compare: 100 } : { revenue: 120 }], + fields: [], + }; + }); + +/** The selection of the LAST `queryDataset` call for a given dataset. */ +const lastSelection = (queryDataset: ReturnType, dataset: string): any => { + const calls = queryDataset.mock.calls.filter((c) => c[0] === dataset); + return calls.length ? calls[calls.length - 1][1] : undefined; +}; + +const schema: DashboardComponentSchema = { + type: 'dashboard', + dateRange: { field: 'close_date', defaultRange: 'this_quarter' }, + widgets: [ + // Dated by the dashboard filter alone — the "Selected Period" tile. + { + id: 'selected', type: 'metric', title: 'Selected period', dataset: 'sales', values: ['revenue'], + compareTo: { kind: 'previousPeriod' }, + }, + // Dated by its own one-bound window, opted out of the dashboard filter — the YTD tile. + { + id: 'ytd', type: 'metric', title: 'Year to date', dataset: 'sales_ytd', values: ['revenue'], + filter: { close_date: { $gte: '{current_year_start}' } }, + filterBindings: { dateRange: false }, + compareTo: { kind: 'previousPeriod' }, + }, + ], +}; + +describe('DashboardRenderer — a compareTo tile under All time (#25970)', () => { + it('compares under the default range, then shows the bare number under All time', async () => { + const user = userEvent.setup(); + const queryDataset = makeQueryDataset(); + render(); + + // Control: the default range is a closed window, so the comparison runs. + await waitFor(() => expect(screen.getAllByTestId('dataset-compare-trend')).toHaveLength(1)); + expect(lastSelection(queryDataset, 'sales').compareTo).toEqual({ kind: 'previousPeriod' }); + expect(lastSelection(queryDataset, 'sales').timeDimensions).toEqual([ + { dimension: 'close_date', dateRange: [expect.any(String), expect.any(String)] }, + ]); + + await user.click(within(screen.getByTestId('dashboard-filter-dateRange')).getByRole('combobox')); + await user.click(await screen.findByRole('option', { name: 'All time' })); + + await waitFor(() => expect(lastSelection(queryDataset, 'sales')).toEqual({ dimensions: [], measures: ['revenue'] })); + // The YTD tile never shows a number (see below), so this one is the selected-period tile. + expect(await screen.findByText('120')).toBeInTheDocument(); + expect(screen.queryByTestId('dataset-compare-trend')).not.toBeInTheDocument(); + // The one refusal left on the page is the YTD tile's. + expect(screen.getAllByRole('alert')).toHaveLength(1); + }); + + it('keeps the refusal for an author-written one-bound window', async () => { + const queryDataset = makeQueryDataset(); + render(); + expect(await screen.findByRole('alert')).toHaveTextContent(/dated window to shift/); + expect(lastSelection(queryDataset, 'sales_ytd').compareTo).toEqual({ kind: 'previousPeriod' }); + }); +}); diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.test.tsx index 3f2fa9ef2f1..8bdc99038ce 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.test.tsx @@ -29,7 +29,7 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ }, })); -import { DatasetWidget, extractDateWindows } from '../DatasetWidget'; +import { DatasetWidget, extractDateWindows, filterDatesField } from '../DatasetWidget'; afterEach(() => { cleanup(); @@ -204,6 +204,112 @@ describe('DatasetWidget — lowering the comparison window (#3337 point 5)', () }); }); +describe('filterDatesField', () => { + it('finds a date condition at the top level, under $and, $or and $not', () => { + expect(filterDatesField(Q2)).toBe(true); + expect(filterDatesField({ $and: [{ stage: 'won' }, { close_date: { $gte: '2026-01-01' } }] })).toBe(true); + expect(filterDatesField({ $or: [{ stage: 'won' }, { close_date: '2026-04-01' }] })).toBe(true); + expect(filterDatesField({ close_date: { $not: { $lt: '2026-04-01T00:00:00Z' } } })).toBe(true); + }); + + it('finds nothing in a filter that dates no field', () => { + expect(filterDatesField(undefined)).toBe(false); + expect(filterDatesField({ stage: 'won', amount: { $gte: 1000, $lte: 5000 } })).toBe(false); + expect(filterDatesField({ stage: { $in: ['won', 'lost'] } })).toBe(false); + }); + + it('with a dimension named, reads that field alone', () => { + const filter = { created_at: { $gte: '2026-01-01', $lte: '2026-12-31' } }; + expect(filterDatesField(filter, 'created_at')).toBe(true); + expect(filterDatesField(filter, 'close_date')).toBe(false); + }); +}); + +// objectstack#25970 — the reader picked All time on the dashboard date filter, +// so the widget's resolved filter dates nothing: there is no window to compare +// against, and the tile shows its number instead of the executor's refusal. +describe('DatasetWidget — a filter that dates nothing asks for no comparison (#25970)', () => { + // Answers the way the executor does: `compareTo` with no shiftable window is + // refused (`resolveCompareDimension`), anything else answers a row. + const executorLike = () => makeSource(async (_d, s) => { + if (s.compareTo && !(s.timeDimensions ?? []).some((t: any) => t.dateRange)) { + throw new Error( + 'Dataset query failed: 400 Bad Request — [dataset-executor] compareTo needs a dated window to shift', + ); + } + return { rows: [{ revenue: 120 }] }; + }); + + it('sends no compareTo for a widget with no filter and renders the number with no badge', async () => { + const src = executorLike(); + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + // The same query a widget without `compareTo` sends. + expect(selectionOf(src)).toEqual({ dimensions: [], measures: ['revenue'] }); + expect(await screen.findByText('120')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByTestId('dataset-compare-trend')).not.toBeInTheDocument(); + }); + + it('keeps a non-date filter as it is and sends no compareTo', async () => { + const src = executorLike(); + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + expect(selectionOf(src)).toEqual({ dimensions: [], measures: ['revenue'], runtimeFilter: { stage: 'won' } }); + expect(await screen.findByText('120')).toBeInTheDocument(); + }); + + it('sends no compareTo when the named dimension is the one left undated', async () => { + const src = executorLike(); + const filter = { created_at: { $gte: '2026-01-01', $lte: '2026-12-31' } }; + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + // Nothing lowered either: the window stays in the filter, as without compareTo. + expect(selectionOf(src)).toEqual({ dimensions: [], measures: ['revenue'], runtimeFilter: filter }); + }); + + // The ruling: an author-declared one-bound window (YTD) STAYS an error. The + // executor and the spec define a comparison only against a two-bound window, + // and this one was written by the author, not removed by the reader. + it('still sends compareTo for a one-bound `$gte` window, and shows the executor’s refusal', async () => { + const src = executorLike(); + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + expect(selectionOf(src).compareTo).toEqual({ kind: 'previousPeriod' }); + expect(selectionOf(src).timeDimensions).toBeUndefined(); + expect(await screen.findByRole('alert')).toHaveTextContent(/compareTo needs a dated window to shift/); + }); +}); + describe('DatasetWidget — showing the comparison the executor returned', () => { it('renders the KPI delta + window label from the __compare column', async () => { const src = makeSource(async () => ({ From 9f70e1fbb1f7c91b7b1c232b078bc6ad1f73808f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:03:48 +0000 Subject: [PATCH 2/7] test(plugin-dashboard): date the ignoring-family control with one bound A filter that dates nothing is now asked no comparison by every family, so the control's refusal needs a date condition that is no closed window. Claude-Session: https://claude.ai/code/session_01RJW3o457EFFwmLN1429vn9 Co-authored-by: Claude --- ...setWidget.compareTo.ignoredFamilies-7495.test.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.ignoredFamilies-7495.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.ignoredFamilies-7495.test.tsx index 650a8d40be7..31462f18c70 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.ignoredFamilies-7495.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.ignoredFamilies-7495.test.tsx @@ -168,7 +168,13 @@ describe('DatasetWidget — no dated window: an ignoring family draws its chart * The dataset executor refuses a `compareTo` it has no dated window to shift * (a dataset-invalid error). A widget that forwarded `compareTo` showed that * refusal in place of its chart; one that forwards nothing is never refused. + * + * The filter dates its field with ONE bound, so it is a date condition that is + * no closed window: a filter that dates nothing at all is asked no comparison + * by every family (objectstack#25970), which would leave the control below + * nothing to refuse. */ + const ONE_BOUND = { owner: 'u1', close_date: { $gte: '2026-01-01' } }; const refusingSource = () => ({ queryDataset: vi.fn(async (_dataset: string, selection: any) => { if (selection?.compareTo && !selection?.timeDimensions?.length) { @@ -181,10 +187,10 @@ describe('DatasetWidget — no dated window: an ignoring family draws its chart }), }); - it('a pie with compareTo and an undated filter renders the pie', async () => { + it('a pie with compareTo and no closed window renders the pie', async () => { const src = refusingSource(); const { queryByRole } = renderWidget( - { type: 'pie', dataset: 'sales', dimensions: ['stage'], values: ['revenue'], filter: { owner: 'u1' }, compareTo: { kind: 'previousYear' } }, + { type: 'pie', dataset: 'sales', dimensions: ['stage'], values: ['revenue'], filter: ONE_BOUND, compareTo: { kind: 'previousYear' } }, src, ); @@ -196,7 +202,7 @@ describe('DatasetWidget — no dated window: an ignoring family draws its chart it('CONTROL: a bar with the same compareTo and filter still asks, and shows the refusal', async () => { const src = refusingSource(); const { findByRole } = renderWidget( - { type: 'bar', dataset: 'sales', dimensions: ['stage'], values: ['revenue'], filter: { owner: 'u1' }, compareTo: { kind: 'previousYear' } }, + { type: 'bar', dataset: 'sales', dimensions: ['stage'], values: ['revenue'], filter: ONE_BOUND, compareTo: { kind: 'previousYear' } }, src, ); From 5fddf6bf096af9695fa0ea07cf6e0bfecd49df97 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:10:46 +0000 Subject: [PATCH 3/7] test(plugin-dashboard): date the compareTo fixtures that answered a comparison Four fixtures declared compareTo with no dated filter and had the mock answer a __compare column the executor would have refused. A comparison is now asked only over a dated window, so each states one. Claude-Session: https://claude.ai/code/session_01RJW3o457EFFwmLN1429vn9 Co-authored-by: Claude --- .../__tests__/DatasetWidget.colorVariant.test.tsx | 6 ++++-- .../DatasetWidget.comboPresentation.test.tsx | 7 ++++++- ...atasetWidget.dimensionlessMeasures-11261.test.tsx | 6 +++++- .../src/__tests__/DatasetWidget.test.tsx | 12 ++++++++---- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.colorVariant.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.colorVariant.test.tsx index 926f153ab86..419c0d0ea45 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.colorVariant.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.colorVariant.test.tsx @@ -152,12 +152,14 @@ describe('DatasetWidget metric card — the declared colorVariant (#3359)', () = it('keeps the comparison trend row untouched by the accent', async () => { const rows = [{ revenue: 510000, revenue__compare: 400000 }]; - const plain = await renderMetric({ compareTo: { kind: 'previousPeriod' } }, rows); + // A comparison is asked only over a dated window (objectstack#25970). + const filter = { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } }; + const plain = await renderMetric({ filter, compareTo: { kind: 'previousPeriod' } }, rows); const plainHtml = plain.innerHTML; cleanup(); // The delta row renders (that is what makes this case worth pinning) … expect(plainHtml).toContain('data-testid="dataset-compare-trend"'); - const tinted = await renderMetric({ compareTo: { kind: 'previousPeriod' }, colorVariant: 'danger' }, rows); + const tinted = await renderMetric({ filter, compareTo: { kind: 'previousPeriod' }, colorVariant: 'danger' }, rows); // … and the tinted render differs from it in exactly one place: the value's // class attribute. The trend's own up/down colouring is data-driven and must // not inherit the widget's accent. diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.comboPresentation.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.comboPresentation.test.tsx index fbad0434f43..af44a4fa9f7 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.comboPresentation.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.comboPresentation.test.tsx @@ -213,7 +213,12 @@ describe('DatasetWidget — a comparison overlay carries no merged mark (objectu fields, })), }; - await renderWidget(comboWidget({ compareTo: { kind: 'previousYear' }, chartConfig: retiredComboChartConfig }), src); + await renderWidget(comboWidget({ + // A comparison is asked only over a dated window (objectstack#25970). + filter: { due_date: { $gte: '2026-01-01', $lte: '2026-12-31' } }, + compareTo: { kind: 'previousYear' }, + chartConfig: retiredComboChartConfig, + }), src); const series = lastChartSchema.series; expect(series.map((s: any) => [s.dataKey, s.variant])).toEqual([ diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.dimensionlessMeasures-11261.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.dimensionlessMeasures-11261.test.tsx index 1038beae8d1..f54febf6e73 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.dimensionlessMeasures-11261.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.dimensionlessMeasures-11261.test.tsx @@ -180,7 +180,11 @@ describe('objectui#11261 — a dimensionless chart renders one mark per measure' // A dimensionless bar used to be a tile that showed its comparison as a // delta. As a chart it keeps asking for one, so the overlay has to draw. const container = await renderChart( - { id: 'rc', type: 'bar', dataset: 'sales', values: ['revenue', 'cost'], compareTo: { kind: 'previousPeriod' } }, + { + id: 'rc', type: 'bar', dataset: 'sales', values: ['revenue', 'cost'], + // A comparison is asked only over a dated window (objectstack#25970). + filter: { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } }, compareTo: { kind: 'previousPeriod' }, + }, [{ revenue: 510000, cost: 120000, revenue__compare: 400000, cost__compare: 100000 }], ); await waitFor(() => expect(barMarks(container)).toBe(4)); diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx index f5daa69aafd..0cb2f5c3884 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx @@ -197,17 +197,21 @@ describe('DatasetWidget', () => { // spelling is now invalid METADATA, rejected where it is authored rather than // laundered here. it('forwards compareTo unchanged — the executor’s own { kind, dimension? } contract', async () => { + // A comparison is asked only over a dated window (objectstack#25970); the + // window moves into `timeDimensions` (see DatasetWidget.compareTo.test.tsx). + const filter = { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } }; + const lowered = { timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-04-01', '2026-06-30'] }] }; const withDimension = { kind: 'previousPeriod', dimension: 'close_date' }; const src1 = makeSource(async () => ({ rows: [{ revenue: 1 }] })); - render(); - await waitFor(() => expect(src1.queryDataset).toHaveBeenCalledWith('sales', { dimensions: [], measures: ['revenue'], compareTo: withDimension })); + render(); + await waitFor(() => expect(src1.queryDataset).toHaveBeenCalledWith('sales', { dimensions: [], measures: ['revenue'], ...lowered, compareTo: withDimension })); // `dimension` omitted — the executor resolves it (or fails loudly naming the // candidates). The widget must NOT fill one in on its way past. const bare = { kind: 'previousYear' }; const src2 = makeSource(async () => ({ rows: [{ revenue: 1 }] })); - render(); - await waitFor(() => expect(src2.queryDataset).toHaveBeenCalledWith('sales', { dimensions: [], measures: ['revenue'], compareTo: bare })); + render(); + await waitFor(() => expect(src2.queryDataset).toHaveBeenCalledWith('sales', { dimensions: [], measures: ['revenue'], ...lowered, compareTo: bare })); expect(src2.queryDataset.mock.calls[0][1].compareTo).not.toHaveProperty('dimension'); }); From b256ae4446950b92a7d4b53f5e8d1b700e90621f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:17:04 +0000 Subject: [PATCH 4/7] chore(sdui): re-record the manifest for the DatasetWidget change Claude-Session: https://claude.ai/code/session_01RJW3o457EFFwmLN1429vn9 Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 1555759c7e3..cf683b3f4e8 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "ad9e0875b83f339e03c528fc3be774592615dfdc5ee5fe9becc4376ddb5f0d52" + "registrySourcesSha256": "2350ad17f4b66d60d4bafb922d663e21d279b2edd11cb84257bfc7dad7a39ffc" } } From 58d56b77e602daaa67af78ae84e7f434104fdbd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:52:48 +0000 Subject: [PATCH 5/7] chore(sdui): re-record the manifest after merging main Claude-Session: https://claude.ai/code/session_01RJW3o457EFFwmLN1429vn9 Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index f65f470a705..3a306a46f50 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "3fe916234f1b5346bff70f6f99322077630b851ebef81c9ae6ba8c4ac08e2063" + "registrySourcesSha256": "ec2e496fc3fc561a7e9c0a707c3d813306de79ae79be642b1e24b531584d9d5f" } } From 0d69852198a81340c98ec88354efd3b24d1baf83 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:00:23 +0000 Subject: [PATCH 6/7] test(e2e): date the metric-tile-height delta fixture A comparison is asked only over a dated window, so the delta tile's fixture states one. Claude-Session: https://claude.ai/code/session_01RJW3o457EFFwmLN1429vn9 Co-authored-by: Claude --- ui/e2e/dashboard-metric-tile-height.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/e2e/dashboard-metric-tile-height.spec.ts b/ui/e2e/dashboard-metric-tile-height.spec.ts index 40d5e8fa2c7..7280815a14b 100644 --- a/ui/e2e/dashboard-metric-tile-height.spec.ts +++ b/ui/e2e/dashboard-metric-tile-height.spec.ts @@ -139,7 +139,11 @@ const metric = (id, title, layout, extra) => ({ id, type: 'metric', title, dataset: 'opportunity_metrics', values: ['total_amount'], layout, ...extra, }); const BARE = (layout) => metric('bare', 'Total Pipeline', layout); -const DELTA = (layout) => metric('delta', 'Pipeline vs prior', layout, { compareTo: { kind: 'previousPeriod' } }); +// A comparison is asked only over a dated window, so the delta tile states one. +const DELTA = (layout) => metric('delta', 'Pipeline vs prior', layout, { + filter: { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } }, + compareTo: { kind: 'previousPeriod' }, +}); // Each case is its own dashboard (see the header). 12 columns, a quarter-width tile. const CASES = [ From 57ba9c8c8ea1829d9bb7df3ed7859127b864b262 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 14:01:18 +0000 Subject: [PATCH 7/7] chore(sdui): re-record the plugin-dashboard source record after merging main Claude-Session: https://claude.ai/code/session_01RJW3o457EFFwmLN1429vn9 Co-authored-by: Claude --- scripts/sdui-manifest.sources/plugin-dashboard.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/plugin-dashboard.json b/scripts/sdui-manifest.sources/plugin-dashboard.json index 8d006a093fa..5371ccddf7a 100644 --- a/scripts/sdui-manifest.sources/plugin-dashboard.json +++ b/scripts/sdui-manifest.sources/plugin-dashboard.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-dashboard","sources":33,"sha256":"c8aaf01c370229a85b2dedb0a4927d9edca345d3f029c35e6d732ca637b6dc08","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-dashboard","sources":33,"sha256":"20c947f73887cf0051d66da0c9daca4045ead2401c7adf47a0b15cad9c51efc7","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}