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
16 changes: 16 additions & 0 deletions .changeset/30140-compare-to-forward-presets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/core': patch
'@object-ui/i18n': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/plugin-charts': patch
---

fix(core): a compareTo widget on a forward date range (next month, next quarter, next 30 days) compares with the period just before it, not with itself

Clause-②: no
Metadata-protocol: none

- **`@object-ui/core`: `shiftFilterByCompareTo`** with `kind: 'previousPeriod'` moved only `current_*` / `today` tokens, so a dashboard filtered to `next_month` or `next_quarter` compared the window with itself and the delta always read 0, and `next_30_days` (`{today}` … `{30_days_from_now}`) moved only its start and compared with a window that overlapped it on all but one day. A forward window now steps back one period: `{next_month_*}` becomes this month, `{next_quarter_*}` this quarter, and a `{today}` … `{N_days_from_now}` pair on one field becomes the N + 1 days that end yesterday (`{(N+1)_days_ago}` … `{yesterday}`), the same window the dataset executor computes for that range. Every other token, and the 13 backward presets, compare as before.
- **`@object-ui/core`: `compareToTrendLabelKey`** names that window: `vsThisMonth` for a forward month, `vsThisQuarter` for a forward quarter, and `vsPreviousPeriod` for a forward rolling window (it read `vsLastMonth`, `vsLastQuarter` and `vsYesterday`).
- **`@object-ui/i18n`:** `dashboard.trend.vsThisMonth` and `dashboard.trend.vsThisQuarter` in all ten locale packs (zh: 较本月, 较本季度).
- **`@object-ui/plugin-dashboard`, `@object-ui/plugin-charts`:** English fallbacks for the two keys in the metric tile and in the chart's comparison series label.
99 changes: 99 additions & 0 deletions ui/packages/core/src/utils/__tests__/compare-to.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,90 @@ describe('shiftFilterByCompareTo', () => {
});
});

// objectstack#30140. A forward window used to keep its own tokens (or, for
// the rolling window, move only its start), so the comparison read the
// window against itself and the delta was 0.
describe('previousPeriod on a forward window', () => {
// Monday 2026-05-25: next month is June, next quarter Q3.
it('next month compares with this month', () => {
const out = shiftFilterByCompareTo(
{ d: { $gte: '{next_month_start}', $lte: '{next_month_end}' } },
{ kind: 'previousPeriod' },
now,
);
expect(out.d).toEqual({ $gte: '2026-05-01', $lte: '2026-05-31' });
});

it('next quarter compares with this quarter', () => {
const out = shiftFilterByCompareTo(
{ d: { $gte: '{next_quarter_start}', $lte: '{next_quarter_end}' } },
{ kind: 'previousPeriod' },
now,
);
expect(out.d).toEqual({ $gte: '2026-04-01', $lte: '2026-06-30' });
});

it('steps back once, across a year boundary too', () => {
// From 2026-12-10, next month and next quarter are in 2027; the period
// before each is December and Q4 2026, never November or Q3.
const dec = new Date(2026, 11, 10, 12, 0);
expect(
shiftFilterByCompareTo(
{ d: { $gte: '{next_month_start}', $lte: '{next_month_end}' } },
{ kind: 'previousPeriod' },
dec,
).d,
).toEqual({ $gte: '2026-12-01', $lte: '2026-12-31' });
expect(
shiftFilterByCompareTo(
{ d: { $gte: '{next_quarter_start}', $lte: '{next_quarter_end}' } },
{ kind: 'previousPeriod' },
dec,
).d,
).toEqual({ $gte: '2026-10-01', $lte: '2026-12-31' });
});

it('today … 30 days from now compares with the 31 days that end yesterday', () => {
// Current window: 2026-05-25 .. 2026-06-24, 31 days.
const out = shiftFilterByCompareTo(
{ d: { $gte: '{today}', $lte: '{30_days_from_now}' } },
{ kind: 'previousPeriod' },
now,
);
expect(out.d).toEqual({ $gte: '2026-04-24', $lte: '2026-05-24' });
});

it('moves a $between rolling window the same way', () => {
const out = shiftFilterByCompareTo(
{ d: { $between: ['{today}', '{7_days_from_now}'] } },
{ kind: 'previousPeriod' },
now,
);
// Current 2026-05-25 .. 2026-06-01 (8 days) → 2026-05-17 .. 2026-05-24.
expect(out.d.$between).toEqual(['2026-05-17', '2026-05-24']);
});

it('leaves a lone {today} and a lone {N_days_from_now} to their old reading', () => {
// Not a forward rolling pair: `{today}` still means yesterday, and a
// bound with no `{today}` partner has no window length to move by.
expect(shiftFilterByCompareTo({ at: '{today}' }, { kind: 'previousPeriod' }, now).at).toBe('2026-05-24');
expect(
shiftFilterByCompareTo({ d: { $lte: '{30_days_from_now}' } }, { kind: 'previousPeriod' }, now).d,
).toEqual({ $lte: '2026-06-24' });
expect(
shiftFilterByCompareTo(
{ a: { $gte: '{today}' }, b: { $lte: '{30_days_from_now}' } },
{ kind: 'previousPeriod' },
now,
),
).toEqual({ a: { $gte: '2026-05-24' }, b: { $lte: '2026-06-24' } });
// Two fields are two conditions, not one window.
expect(
shiftFilterByCompareTo({ a: '{today}', b: '{30_days_from_now}' }, { kind: 'previousPeriod' }, now),
).toEqual({ a: '2026-05-24', b: '2026-06-24' });
});
});

describe('previousYear', () => {
it('re-resolves macros against now shifted back one year', () => {
const filter = {
Expand Down Expand Up @@ -125,6 +209,21 @@ describe('compareToTrendLabelKey', () => {
it('detects today', () => {
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, { d: '{today}' })).toBe('vsYesterday');
});
it('names the current period for a forward month or quarter (objectstack#30140)', () => {
const month = { d: { $gte: '{next_month_start}', $lte: '{next_month_end}' } };
const quarter = { d: { $gte: '{next_quarter_start}', $lte: '{next_quarter_end}' } };
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, month)).toBe('vsThisMonth');
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, quarter)).toBe('vsThisQuarter');
expect(compareToTrendLabelKey({ kind: 'previousYear' }, month)).toBe('vsLastYear');
});
it('reads a forward rolling window as the previous period, not yesterday (objectstack#30140)', () => {
expect(
compareToTrendLabelKey({ kind: 'previousPeriod' }, { d: { $gte: '{today}', $lte: '{30_days_from_now}' } }),
).toBe('vsPreviousPeriod');
expect(
compareToTrendLabelKey({ kind: 'previousPeriod' }, { d: { $between: ['{today}', '{30_days_from_now}'] } }),
).toBe('vsPreviousPeriod');
});
it('falls back to vsPreviousPeriod when no token matches', () => {
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, { status: 'open' })).toBe('vsPreviousPeriod');
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, undefined)).toBe('vsPreviousPeriod');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,56 @@ const RETIRED_PRESET_RANGES: Record<string, { from: string; to: string }> = {

/** The presets the spec added after the retired table, which never held them. */
const FORWARD_PRESETS: readonly string[] = ['next_month', 'next_quarter', 'next_30_days'];
// The compareTo case below runs on these only. A forward preset has no retired
// spelling to compare against, and compareTo on a forward window compares the
// window with itself, which #30140 tracks.
// A forward preset has no retired spelling to compare against, so the
// retired-spelling cases run on these only. The compareTo case runs on all 16.
const RETIRED_PRESETS = [...DATE_RANGE_PRESETS].filter((p) => !FORWARD_PRESETS.includes(p));

/**
* CONTROL for #30140: the previous-period and previous-year windows and the
* trend label of every retired preset at REFERENCE_NOWS[0] (2026-07-15), read
* off `compare-to.ts` at main b7075d99 before the forward fix. The fix must
* leave every one of them as it was.
*/
const RETIRED_COMPARE_AT_REFERENCE: Record<string, {
label: string;
previousPeriod: [string, string];
previousYear: [string, string];
}> = {
today: { label: 'vsYesterday', previousPeriod: ['2026-07-14', '2026-07-14'], previousYear: ['2025-07-15', '2025-07-15'] },
yesterday: { label: 'vsPreviousPeriod', previousPeriod: ['2026-07-14', '2026-07-14'], previousYear: ['2025-07-14', '2025-07-14'] },
this_week: { label: 'vsLastWeek', previousPeriod: ['2026-07-06', '2026-07-12'], previousYear: ['2025-07-14', '2025-07-20'] },
last_week: { label: 'vsLastWeek', previousPeriod: ['2026-07-06', '2026-07-12'], previousYear: ['2025-07-07', '2025-07-13'] },
this_month: { label: 'vsLastMonth', previousPeriod: ['2026-06-01', '2026-06-30'], previousYear: ['2025-07-01', '2025-07-31'] },
last_month: { label: 'vsLastMonth', previousPeriod: ['2026-06-01', '2026-06-30'], previousYear: ['2025-06-01', '2025-06-30'] },
this_quarter: { label: 'vsLastQuarter', previousPeriod: ['2026-04-01', '2026-06-30'], previousYear: ['2025-07-01', '2025-09-30'] },
last_quarter: { label: 'vsLastQuarter', previousPeriod: ['2026-04-01', '2026-06-30'], previousYear: ['2025-04-01', '2025-06-30'] },
this_year: { label: 'vsLastYear', previousPeriod: ['2025-01-01', '2025-12-31'], previousYear: ['2025-01-01', '2025-12-31'] },
last_year: { label: 'vsLastYear', previousPeriod: ['2025-01-01', '2025-12-31'], previousYear: ['2024-01-01', '2024-12-31'] },
last_7_days: { label: 'vsYesterday', previousPeriod: ['2026-07-08', '2026-07-14'], previousYear: ['2025-07-08', '2025-07-15'] },
last_30_days: { label: 'vsYesterday', previousPeriod: ['2026-06-15', '2026-07-14'], previousYear: ['2025-06-15', '2025-07-15'] },
last_90_days: { label: 'vsYesterday', previousPeriod: ['2026-04-16', '2026-07-14'], previousYear: ['2025-04-16', '2025-07-15'] },
};

/**
* The forward presets' comparison at REFERENCE_NOWS[0], a Wednesday,
* 2026-07-15 (#30140). `previousPeriod` is the window immediately before the
* current one: July for August, Q3 for Q4, and for the 31 days from today
* through 2026-08-14 the 31 days that end yesterday.
*/
const FORWARD_COMPARE_AT_REFERENCE: Record<string, {
label: string;
previousPeriod: [string, string];
previousYear: [string, string];
}> = {
next_month: { label: 'vsThisMonth', previousPeriod: ['2026-07-01', '2026-07-31'], previousYear: ['2025-08-01', '2025-08-31'] },
next_quarter: { label: 'vsThisQuarter', previousPeriod: ['2026-07-01', '2026-09-30'], previousYear: ['2025-10-01', '2025-12-31'] },
next_30_days: { label: 'vsPreviousPeriod', previousPeriod: ['2026-06-14', '2026-07-14'], previousYear: ['2025-07-15', '2025-08-14'] },
};

/** Whole calendar days from ISO day `a` to ISO day `b`. */
const daysBetween = (a: string, b: string): number =>
Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86_400_000);

/**
* Reference instants (local time, which is what `resolveDateMacros` reads):
* a mid-quarter Wednesday, a Sunday that closes a week, a Monday that opens
Expand Down Expand Up @@ -151,22 +196,64 @@ describe('dashboard date presets lower through the spec window table', () => {
},
);

it.each(RETIRED_PRESETS)(
'%s keeps its previous-period window and trend label for a compareTo widget',
it('pins a compareTo expectation for every one of the 16 presets', () => {
expect(
Object.keys({ ...RETIRED_COMPARE_AT_REFERENCE, ...FORWARD_COMPARE_AT_REFERENCE }).sort(),
).toEqual([...DATE_RANGE_PRESETS].sort());
});

it.each([...DATE_RANGE_PRESETS])(
'%s compares with the right window and trend label for a compareTo widget',
(preset) => {
const retired = RETIRED_PRESET_RANGES[preset];
const retiredFilter = { created_at: { $gte: retired.from, $lte: retired.to } };
const filter = { created_at: buildFilterCondition(dateDef, { preset }) };
const expected = RETIRED_COMPARE_AT_REFERENCE[preset] ?? FORWARD_COMPARE_AT_REFERENCE[preset];
const at = REFERENCE_NOWS[0];
expect(shiftFilterByCompareTo(filter, { kind: 'previousPeriod' }, at).created_at).toEqual({
$gte: expected.previousPeriod[0],
$lte: expected.previousPeriod[1],
});
expect(shiftFilterByCompareTo(filter, { kind: 'previousYear' }, at).created_at).toEqual({
$gte: expected.previousYear[0],
$lte: expected.previousYear[1],
});
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, filter)).toBe(expected.label);
expect(compareToTrendLabelKey({ kind: 'previousYear' }, filter)).toBe('vsLastYear');

if (!FORWARD_PRESETS.includes(preset)) {
// A retired preset keeps the window and label its retired spelling had.
const retired = RETIRED_PRESET_RANGES[preset];
const retiredFilter = { created_at: { $gte: retired.from, $lte: retired.to } };
for (const now of REFERENCE_NOWS) {
for (const kind of ['previousPeriod', 'previousYear'] as const) {
expect(shiftFilterByCompareTo(filter, { kind }, now), `${preset} ${kind} @ ${now.toString()}`).toEqual(
shiftFilterByCompareTo(retiredFilter, { kind }, now),
);
}
}
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, filter)).toBe(
compareToTrendLabelKey({ kind: 'previousPeriod' }, retiredFilter),
);
return;
}

// A forward preset's previous period ends the day before its own window
// starts, on every reference date, so it never overlaps it.
for (const now of REFERENCE_NOWS) {
for (const kind of ['previousPeriod', 'previousYear'] as const) {
expect(shiftFilterByCompareTo(filter, { kind }, now), `${preset} ${kind} @ ${now.toString()}`).toEqual(
shiftFilterByCompareTo(retiredFilter, { kind }, now),
);
const current = resolveDateMacros(filter, now).created_at as { $gte: string; $lte: string };
const previous = shiftFilterByCompareTo(filter, { kind: 'previousPeriod' }, now).created_at as {
$gte: string;
$lte: string;
};
const label = `${preset} @ ${now.toString()}`;
expect(daysBetween(previous.$lte, current.$gte), label).toBe(1);
if (preset === 'next_30_days') {
// Rolling: the same number of days as the current window.
expect(daysBetween(previous.$gte, previous.$lte), label).toBe(daysBetween(current.$gte, current.$lte));
} else {
// Calendar: the whole period just before, starting on its first day.
expect(previous.$gte.endsWith('-01'), label).toBe(true);
}
}
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, filter)).toBe(
compareToTrendLabelKey({ kind: 'previousPeriod' }, retiredFilter),
);
},
);

Expand Down
Loading
Loading