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
13 changes: 13 additions & 0 deletions .changeset/30197-compare-to-past-presets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/core': patch
---

fix(core): a compareTo widget on an already-past date range (yesterday, last week / month / quarter / year, last N days) compares with the period before it, and previousYear from 29 February stays in February

Clause-②: no
Metadata-protocol: none

- **`shiftFilterByCompareTo`, `kind: 'previousPeriod'`.** `{yesterday}` and the `last_*` tokens were never moved, so a dashboard filtered to `yesterday`, `last_week`, `last_month`, `last_quarter` or `last_year` compared the window with itself and the delta always read 0. They now compare with the period before them: `{yesterday}` with `{2_days_ago}`, and `last_week_*` … `last_year_*` with the week, month, quarter or year before last. The macro vocabulary has no token for "two periods back", so those windows are computed: the `last_*` start is resolved in the display zone, moved back one period, and the end is the day before it.
- **Rolling windows.** `{N_days_ago}` … `{today}` (`last_7_days`, `last_30_days`, `last_90_days`; N + 1 days) moved only its end and compared with the N days ending yesterday, which overlap it. It now compares with the N + 1 days before it, `{(2N+1)_days_ago}` … `{(N+1)_days_ago}`, the same window the dataset executor computes.
- **`kind: 'previousYear'` from 29 February** re-resolved the filter at 1 March of the year before (`new Date(2027, 1, 29)`), so `this_month` compared with March. The day is now clamped to the month's last day, 28 February.
- **`compareToTrendLabelKey`** reads all of these as `vsPreviousPeriod` (they read `vsLastWeek`, `vsLastMonth`, `vsLastQuarter`, `vsLastYear` and `vsYesterday`); no `dashboard.trend.*` key names "the month before last". `today`, the `this_*` and the forward presets compare and label as before.
73 changes: 73 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 @@ -133,7 +133,66 @@ describe('shiftFilterByCompareTo', () => {
});
});

// objectstack#30197. A window that is already past (`{yesterday}`, `last_*`)
// kept its tokens and was compared with itself; a backward rolling window
// (`{N_days_ago}` … `{today}`, N + 1 days) moved only its end.
describe('previousPeriod on an already-past window', () => {
// Monday 2026-05-25.
const shift = (filter: unknown) => shiftFilterByCompareTo(filter, { kind: 'previousPeriod' }, now) as any;

it('yesterday compares with the day before it', () => {
expect(shift({ d: '{yesterday}' }).d).toBe('2026-05-23');
});

it('last week compares with the week before last', () => {
expect(shift({ d: { $gte: '{last_week_start}', $lte: '{last_week_end}' } }).d)
.toEqual({ $gte: '2026-05-11', $lte: '2026-05-17' });
});

it('last month compares with the month before last, whatever their lengths', () => {
expect(shift({ d: { $gte: '{last_month_start}', $lte: '{last_month_end}' } }).d)
.toEqual({ $gte: '2026-03-01', $lte: '2026-03-31' });
// From 2026-03-31: last month is February, the one before it January.
expect(
shiftFilterByCompareTo(
{ d: { $gte: '{last_month_start}', $lte: '{last_month_end}' } },
{ kind: 'previousPeriod' },
new Date(2026, 2, 31, 12, 0),
).d,
).toEqual({ $gte: '2026-01-01', $lte: '2026-01-31' });
});

it('last quarter and last year compare with the ones before them, across a year boundary', () => {
expect(shift({ d: { $gte: '{last_quarter_start}', $lte: '{last_quarter_end}' } }).d)
.toEqual({ $gte: '2025-10-01', $lte: '2025-12-31' });
expect(shift({ d: { $gte: '{last_year_start}', $lte: '{last_year_end}' } }).d)
.toEqual({ $gte: '2024-01-01', $lte: '2024-12-31' });
});

it('N days ago … today compares with the N + 1 days before it', () => {
// Current window: 2026-05-18 .. 2026-05-25, 8 days.
expect(shift({ d: { $gte: '{7_days_ago}', $lte: '{today}' } }).d)
.toEqual({ $gte: '2026-05-10', $lte: '2026-05-17' });
expect(shift({ d: { $between: ['{30_days_ago}', '{today}'] } }).d.$between)
.toEqual(['2026-03-25', '2026-04-24']);
});

it('moves a mixed window bound by bound, each once', () => {
// last month through this month → the month before last through last month.
expect(shift({ d: { $gte: '{last_month_start}', $lte: '{current_month_end}' } }).d)
.toEqual({ $gte: '2026-03-01', $lte: '2026-04-30' });
});
});

describe('previousYear', () => {
it('keeps a leap-day window in February one year back (objectstack#30197)', () => {
const leap = new Date(2028, 1, 29, 8, 0);
expect(
shiftFilterByCompareTo({ d: { $gte: '{month_start}', $lte: '{month_end}' } }, { kind: 'previousYear' }, leap).d,
).toEqual({ $gte: '2027-02-01', $lte: '2027-02-28' });
expect(shiftFilterByCompareTo({ d: '{today}' }, { kind: 'previousYear' }, leap).d).toBe('2027-02-28');
});

it('re-resolves macros against now shifted back one year', () => {
const filter = {
d: { $gte: '{current_quarter_start}', $lte: '{current_quarter_end}' },
Expand Down Expand Up @@ -224,6 +283,20 @@ describe('compareToTrendLabelKey', () => {
compareToTrendLabelKey({ kind: 'previousPeriod' }, { d: { $between: ['{today}', '{30_days_from_now}'] } }),
).toBe('vsPreviousPeriod');
});
it('reads an already-past window as the previous period (objectstack#30197)', () => {
for (const filter of [
{ d: '{yesterday}' },
{ d: { $gte: '{last_week_start}', $lte: '{last_week_end}' } },
{ d: { $gte: '{last_month_start}', $lte: '{last_month_end}' } },
{ d: { $gte: '{last_quarter_start}', $lte: '{last_quarter_end}' } },
{ d: { $gte: '{last_year_start}', $lte: '{last_year_end}' } },
{ d: { $gte: '{7_days_ago}', $lte: '{today}' } },
]) {
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, filter), JSON.stringify(filter)).toBe('vsPreviousPeriod');
}
// A lone {today} still reads yesterday.
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, { d: '{today}' })).toBe('vsYesterday');
});
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 @@ -71,29 +71,36 @@ const FORWARD_PRESETS: readonly string[] = ['next_month', 'next_quarter', 'next_
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.
* The previous-period and previous-year windows and the trend label of every
* retired preset at REFERENCE_NOWS[0], Wednesday 2026-07-15.
*
* CONTROL rows (`today`, `this_*`): as read off `compare-to.ts` at main
* b7075d99, before #30140, and unchanged by #30197.
*
* #30197 rows: an already-past window used to compare with itself
* (`yesterday` with 07-14, `last_month` with June) and `last_N_days` (N + 1
* days through today) with the N days that end yesterday. Each now compares
* with the period just before it: 07-13; the week of 06-29; May; Q1; 2024;
* and for `last_7_days` (07-08..07-15, 8 days) the 8 days 06-30..07-07.
*/
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'] },
yesterday: { label: 'vsPreviousPeriod', previousPeriod: ['2026-07-13', '2026-07-13'], 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'] },
last_week: { label: 'vsPreviousPeriod', previousPeriod: ['2026-06-29', '2026-07-05'], 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'] },
last_month: { label: 'vsPreviousPeriod', previousPeriod: ['2026-05-01', '2026-05-31'], 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'] },
last_quarter: { label: 'vsPreviousPeriod', previousPeriod: ['2026-01-01', '2026-03-31'], 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'] },
last_year: { label: 'vsPreviousPeriod', previousPeriod: ['2024-01-01', '2024-12-31'], previousYear: ['2024-01-01', '2024-12-31'] },
last_7_days: { label: 'vsPreviousPeriod', previousPeriod: ['2026-06-30', '2026-07-07'], previousYear: ['2025-07-08', '2025-07-15'] },
last_30_days: { label: 'vsPreviousPeriod', previousPeriod: ['2026-05-15', '2026-06-14'], previousYear: ['2025-06-15', '2025-07-15'] },
last_90_days: { label: 'vsPreviousPeriod', previousPeriod: ['2026-01-15', '2026-04-15'], previousYear: ['2025-04-16', '2025-07-15'] },
};

/**
Expand Down Expand Up @@ -233,11 +240,11 @@ describe('dashboard date presets lower through the spec window table', () => {
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.
// Every preset's previous period ends the day before its own window
// starts, on every reference date, so it never overlaps it and never is
// it (#30140, #30197).
for (const now of REFERENCE_NOWS) {
const current = resolveDateMacros(filter, now).created_at as { $gte: string; $lte: string };
const previous = shiftFilterByCompareTo(filter, { kind: 'previousPeriod' }, now).created_at as {
Expand All @@ -246,14 +253,21 @@ describe('dashboard date presets lower through the spec window table', () => {
};
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.
if (/_days$|^today$|^yesterday$|_week$/.test(preset)) {
// A rolling window, a day or a week: the same number of days.
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.
// A month, quarter or year: the whole period just before, from its first day.
expect(previous.$gte.endsWith('-01'), label).toBe(true);
}
}

// previousYear from a leap day reads the year before on 28 February, so
// a February window stays February (#30197).
expect(
shiftFilterByCompareTo(filter, { kind: 'previousYear' }, new Date(2028, 1, 29, 8, 0)),
`${preset} previousYear @ 2028-02-29`,
).toEqual(resolveDateMacros(filter, new Date(2027, 1, 28, 8, 0)));
},
);

Expand Down
Loading
Loading