Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/analytics-window-timedimension-no-bucket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
"@objectstack/service-analytics": patch
---

fix(service-analytics): a `timeDimensions` entry used only as a date WINDOW no longer buckets the grid (#5688)

**Observable behaviour change — read this if you render, page, or assert on
dataset responses.** A selection that used a date dimension only as a window —
`timeDimensions: [{ dimension, dateRange }]` with no `granularity`, and the
dimension NOT listed in `selection.dimensions` — used to have the dataset
dimension's declared `dateGranularity` filled in anyway. That made the entry a
`GROUP BY` item, so the response grew a time column nobody selected and every
row split per bucket. "Count by Owner" plus a dashboard date-range filter came
back as "by Owner × month":

```
before fields [owner, close_date, opp_count]
rows [{owner:'u1', close_date:'2026-01', opp_count:1},
{owner:'u1', close_date:'2026-02', opp_count:1},
{owner:'u2', close_date:'2026-01', opp_count:1}]

after fields [owner, opp_count]
rows [{owner:'u1', opp_count:2},
{owner:'u2', opp_count:1}]
```

Both the **row count and the column set** change for such a selection: the extra
month column disappears and rows that were split per bucket collapse back into
one row per selected dimension tuple. A KPI single-value card that was reading
the first of several month rows now reads the only row. Consumers that pinned
the previous shape (a snapshot of `fields`, a row count, a hard-coded column
index) need updating; consumers that render the response's own `fields` do not.

Three conditions had to hold together to be affected, so a selection outside
them is byte-identical: the dataset dimension declares an explicit
`dateGranularity`, the `timeDimensions` entry states no `granularity`, and
`selection.dateGranularity` is unset.

**What still buckets, unchanged.** An entry is bucketed when the request says
that date is being bucketed: the dimension is one of the selection's own
`dimensions`, the entry carries its own `granularity` (#4033 — still projected
as a column even when not selected), or `selection.dateGranularity` is set. The
granularity *precedence* chain is untouched. A dataset dimension's
`dateGranularity` says how that date renders **when** grouped — it is no longer
read as a request to group by it.

**`compareTo` alignment (#3588/#4870) holds by construction.** The comparison
pass re-enters the same query builder with the same grid dimensions, differing
only in the shifted `dateRange`, so both passes bucket an entry alike or not at
all — never one of each, which was the state that left every `__compare` column
empty. For a window-only anchor this **repairs** the comparison rather than
preserving it: the merge has always keyed on `selection.dimensions` alone, so
the backfilled bucket column sat outside the merge key, and with several
month-split rows per group the comparison value landed on whichever row the
index held last while the others read a confident `0`.

Also fixed, same root cause: a time column that IS projected via
`timeDimensions` (an entry carrying its own `granularity`, never listed under
`dimensions`) now carries its dataset `label` in `fields` instead of a bare
`type` — the label enrichment walked `selection.dimensions` only.
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@
* the change ADDS field entries that were absent, it narrows no rule and
* removes no `??` limb, so nothing downstream can gain a finding from it.
* Predicted 8 red / 4 green; measured exactly that.
*
* ## Amended by #5688
*
* Two cases here were written as a deliberate FLIP TARGET: the pair that fed a
* `timeDimensions` entry carrying only a `dateRange` asserted, verbatim, that
* the entry acquired the dataset's default bucket and became a second GROUP BY.
* #5688 narrowed that backfill, so both now assert the opposite column set and,
* additionally, the grid substance the descriptors describe (one row per owner,
* no month split). The reverse verification above is unaffected — reverting
* #5537's seam still reds exactly the "all base measures filter-scoped" cases,
* and the flipped pair differs only in what the correct column set IS.
*/

import { describe, it, expect } from 'vitest';
Expand Down Expand Up @@ -253,27 +264,26 @@ describe('#5537 — the single-query path already described its dimensions (guar
});

/**
* The CONTROL for the `compareTo` case in the next block, and the reason the
* expectation there carries an unlabelled `close_date`.
* The CONTROL for the `compareTo` case in the next block: the two selections
* differ only in whether their measures carry filters, so their descriptors
* must agree column for column.
*
* A `timeDimensions` entry that resolves a granularity is GROUPED BY, so it is
* a COLUMN of the result and every producer of this shape projects it (#4033),
* even when the caller never listed it under `dimensions`. It reaches `fields`
* with a `type` and no `label`, because the label enrichment in `queryDataset`
* walks `selection.dimensions` — and that is true on THIS path, which never
* had the #5537 defect. Pinned here so the pair reads as convergence rather
* than as something the fix introduced.
* **This pin was FLIPPED by #5688 and now carries the opposite fact.** It used
* to assert an unlabelled `close_date` descriptor and three month-split rows,
* because a `timeDimensions` entry carrying only a `dateRange` had the
* dataset's default granularity filled in — turning a WINDOW into a second
* GROUP BY. #5688 narrowed that backfill: a window-only entry stays a filter,
* so there is no `close_date` column on either path and `usr_1` is one row
* again. Asserted as substance, not as an absence: the row values pin that the
* three in-window opportunities landed in ONE `usr_1` bucket rather than being
* split across months, which is the defect's actual signature.
*
* That the column exists AT ALL is itself a defect, filed as #5688 and
* deliberately not fixed here: a `timeDimensions` entry carrying only a
* `dateRange` gets the dataset's default granularity filled in, which turns a
* WINDOW into a second GROUP BY — so this selection also comes back split by
* month. It reproduces identically on this path, i.e. independently of #5537,
* and settling it changes the response SHAPE (a row count, not a label), which
* is not a call to make as a rider. Both asserted verbatim so the day #5688
* lands, this pair goes red and gets updated on purpose.
* The projection rule itself is unchanged (#4033: an entry that DOES resolve a
* granularity is grouped, so it is a column) — see
* `dataset-window-timedimension-bucketing.test.ts`, which pins both sides of
* the narrowed criterion plus the label the projected column now carries.
*/
it('a granular `timeDimensions` column is projected here too — unlabelled, on this path as well', async () => {
it('a window-only `timeDimensions` entry adds no column here — same as the filtered path', async () => {
const result = await svc().queryDataset(
dataset,
{
Expand All @@ -286,18 +296,19 @@ describe('#5537 — the single-query path already described its dimensions (guar
);
expect(descriptors(result.fields)).toEqual([
{ name: 'owner', type: 'string', label: 'Owner' },
{ name: 'close_date', type: 'time' },
{ name: 'opp_count', type: 'number', label: 'Opportunities' },
{ name: 'opp_count__compare', type: 'number', label: 'Opportunities' },
]);
// #5688, stated as data rather than as prose: `usr_1` is one owner and comes
// back as two rows because the window entry acquired a `month` bucket. The
// descriptors are honest about the grid — the grid is what is wrong.
expect(result.rows.map((r) => [r.owner, r.close_date])).toEqual([
['usr_1', '2026-01'],
['usr_1', '2026-02'],
['usr_2', '2026-02'],
// The grid the descriptors describe: one row per owner, the window applied
// as a filter. `usr_1` has three in-window opportunities (two in January,
// one in February) and they aggregate into a single bucket — before #5688
// this was two rows carrying 2 and 1.
expect(result.rows).toEqual([
{ owner: 'usr_1', opp_count: 3, opp_count__compare: 0 },
{ owner: 'usr_2', opp_count: 1, opp_count__compare: 0 },
]);
// …and no row carries a column no descriptor mentions.
expect(result.rows.every((r) => !('close_date' in r))).toBe(true);
});
});

Expand Down Expand Up @@ -398,15 +409,20 @@ describe('#5537 — all base measures filter-scoped: the dimension is described
},
CTX,
);
// Identical to the control in the previous block, measure for measure
// including the unlabelled `close_date` a granular `timeDimensions` entry
// projects on BOTH paths.
// Identical to the control in the previous block, column for column
// including the ABSENCE of a `close_date` column, which since #5688 a
// window-only `timeDimensions` entry no longer mints on either path.
expect(descriptors(result.fields)).toEqual([
{ name: 'owner', type: 'string', label: 'Owner' },
{ name: 'close_date', type: 'time' },
{ name: 'won_count', type: 'number', label: 'Won' },
{ name: 'won_count__compare', type: 'number', label: 'Won' },
]);
// Same substance as the control: one row per owner, no month split. The two
// paths converge on the grid as well as on the descriptors.
expect(result.rows).toEqual([
{ owner: 'usr_1', won_count: 1, won_count__compare: 0 },
{ owner: 'usr_2', won_count: 1, won_count__compare: 0 },
]);
});

it('the `totals` grid re-enters the same pass and still describes the dimension', async () => {
Expand Down
Loading
Loading