diff --git a/.changeset/30197-compare-to-past-presets.md b/.changeset/30197-compare-to-past-presets.md new file mode 100644 index 00000000000..6d83b5186b2 --- /dev/null +++ b/.changeset/30197-compare-to-past-presets.md @@ -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. diff --git a/ui/packages/core/src/utils/__tests__/compare-to.test.ts b/ui/packages/core/src/utils/__tests__/compare-to.test.ts index fe972631944..fccc35ef02d 100644 --- a/ui/packages/core/src/utils/__tests__/compare-to.test.ts +++ b/ui/packages/core/src/utils/__tests__/compare-to.test.ts @@ -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}' }, @@ -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'); diff --git a/ui/packages/core/src/utils/__tests__/dashboard-filters.presetWindows-29116.test.ts b/ui/packages/core/src/utils/__tests__/dashboard-filters.presetWindows-29116.test.ts index 36f14839351..2f3045b2201 100644 --- a/ui/packages/core/src/utils/__tests__/dashboard-filters.presetWindows-29116.test.ts +++ b/ui/packages/core/src/utils/__tests__/dashboard-filters.presetWindows-29116.test.ts @@ -71,10 +71,17 @@ 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 = { 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'] }, }; /** @@ -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 { @@ -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))); }, ); diff --git a/ui/packages/core/src/utils/compare-to.ts b/ui/packages/core/src/utils/compare-to.ts index ceaf203f1c6..8f7c6eeea14 100644 --- a/ui/packages/core/src/utils/compare-to.ts +++ b/ui/packages/core/src/utils/compare-to.ts @@ -24,10 +24,14 @@ import { resolveDateMacros } from './date-macros.js'; * filter is written with date macros. A FORWARD window steps back the same * way (objectstack#30140): `next_month_*` / `next_quarter_*` become the * current month / quarter, and the rolling `{today}` … `{N_days_from_now}` - * pair becomes the N + 1 days that end yesterday. + * pair becomes the N + 1 days that end yesterday. A window that is ALREADY + * past steps back once more (objectstack#30197): `{yesterday}` becomes the + * day before it, `last_week_*` … `last_year_*` the period before last, and + * the rolling `{N_days_ago}` … `{today}` pair the N + 1 days before it. * - `kind: 'previousYear'` — re-resolve the filter's date macros against a * `now` shifted back one calendar year (same week / month / quarter, one year - * earlier). + * earlier). From a 29 February the shifted day is 28 February, so the month + * stays February (objectstack#30197). * - `dimension` — OPTIONAL, and deliberately never read on this path. It names * the dataset time dimension whose `dateRange` the EXECUTOR shifts. That * resolution lives at the producer of the comparison so that every caller @@ -80,9 +84,10 @@ const CURRENT_TO_LAST_TOKENS: Record = { * current one. Without this map a forward window kept its own tokens and was * compared with itself, so the delta always read 0 (objectstack#30140). * - * Only the pairs that close a window are listed. `next_week_start` and - * `next_year_start` have no `_end` partner in the macro vocabulary, so no - * preset or closed filter is written with them. + * Only the forward periods a preset emits are listed. The spec vocabulary + * also declares `next_week_*` and `next_year_*`, but this package's resolver + * (`date-macros.ts`) has no `next_week_end` / `next_year_end`, so a closed + * window written with them does not resolve here in the first place. */ const NEXT_TO_CURRENT_TOKENS: Record = { next_month_start: 'current_month_start', @@ -91,14 +96,45 @@ const NEXT_TO_CURRENT_TOKENS: Record = { next_quarter_end: 'current_quarter_end', }; -/** Every token `previousPeriod` moves back one period, backward and forward. */ +/** Every token `previousPeriod` moves back one period by renaming it. */ const PREVIOUS_PERIOD_TOKENS: Record = { ...CURRENT_TO_LAST_TOKENS, ...NEXT_TO_CURRENT_TOKENS, }; +/** `YYYY-MM-DD` moved by whole days, months and years, on the UTC calendar. */ +function isoShift(iso: string, { days = 0, months = 0, years = 0 }): string { + const [y, m, d] = iso.split('-').map(Number); + return new Date(Date.UTC(y + years, m - 1 + months, d + days)).toISOString().slice(0, 10); +} + +/** + * The period before an already-past one, for the tokens that have no "two + * periods back" spelling (objectstack#30197). The macro vocabulary + * (`DATE_MACRO_TOKENS` in `@objectstack/spec/data`) stops at `last_*`, and the + * parameterised `{N_weeks_ago}` / `{N_months_ago}` grammar counts from today + * rather than from a period's first day, so no token names the month before + * last. Each window is therefore COMPUTED: the `last_*` start is resolved in + * the display zone like every other token, then moved back one period with + * calendar arithmetic, and the end is the day before that `last_*` start. The + * result is an ISO day, which `resolveDateMacros` passes through untouched. + * + * `{yesterday}` does have a spelling, `{2_days_ago}`, and keeps it. + */ +function lastPeriodBefore(token: string, now: Date): string | undefined { + const m = token.match(/^last_(week|month|quarter|year)_(start|end)$/); + if (!m) return token === 'yesterday' ? '{2_days_ago}' : undefined; + const [, unit, edge] = m; + const lastStart = resolveDateMacros(`{last_${unit}_start}`, now); + if (edge === 'end') return isoShift(lastStart, { days: -1 }); + if (unit === 'week') return isoShift(lastStart, { days: -7 }); + if (unit === 'month') return isoShift(lastStart, { months: -1 }); + if (unit === 'quarter') return isoShift(lastStart, { months: -3 }); + return isoShift(lastStart, { years: -1 }); +} + const WHOLE_TOKEN_RE = /^\$?\{([a-zA-Z0-9_]+)\}$/; -const DAYS_FROM_NOW_RE = /^(\d+)_days_from_now$/; +const ROLLING_DAYS_RE = /^(\d+)_days_(ago|from_now)$/; /** The bare token a string is made of, or undefined when it is anything else. */ function wholeToken(value: unknown): string | undefined { @@ -107,78 +143,88 @@ function wholeToken(value: unknown): string | undefined { } /** - * The two places a forward rolling window can sit in: the operator entries of - * one field's condition (`{ $gte: '{today}', $lte: '{30_days_from_now}' }`) - * or the two members of a `$between` array. Returns the replacement for each - * bound, by position, when the pair is `{today}` and `{N_days_from_now}`, else - * undefined. Two FIELDS that hold those tokens (`{ a: '{today}', b: … }`) are - * not one window, which is why only `$`-keyed entries are read. + * The two places a rolling window can sit in: the operator entries of one + * field's condition (`{ $gte: '{today}', $lte: '{30_days_from_now}' }`) or the + * two members of a `$between` array. Returns the replacement for each bound, + * by position, when the pair is `{today}` and one `{N_days_from_now}` or + * `{N_days_ago}`, else undefined. Two FIELDS that hold those tokens + * (`{ a: '{today}', b: … }`) are not one window, which is why only `$`-keyed + * entries are read. + * + * Either window holds today and N other days, N + 1 days in all + * (`next_30_days`, `last_30_days`; `DATE_RANGE_PRESET_MACRO_WINDOWS`), so both + * bounds move back by N + 1 days: + * + * - forward (objectstack#30140): `{today}` → `{(N+1)_days_ago}` and + * `{N_days_from_now}` → `{1_day_ago}`, the N + 1 days that end yesterday; + * - backward (objectstack#30197): `{N_days_ago}` → `{(2N+1)_days_ago}` and + * `{today}` → `{(N+1)_days_ago}`, the N + 1 days that end the day before + * the window starts. * - * That window holds today and the N days after it, N + 1 days in all - * (`next_30_days`, `DATE_RANGE_PRESET_MACRO_WINDOWS`). The equal-length window - * just before it ends yesterday and starts N + 1 days ago, so both bounds move - * back by the window's own length: `{today}` → `{(N+1)_days_ago}` and - * `{N_days_from_now}` → `{yesterday}`. The generic `today` → `yesterday` - * substitution would have moved only the start, overlapping the current window - * on every day but one. + * The generic `today` → `yesterday` substitution would have moved only one + * bound, overlapping the current window on every day but one. `{1_day_ago}` + * is written rather than `{yesterday}` because `{yesterday}` is itself a token + * the substitution moves back. */ -function forwardRollingPair(values: readonly unknown[]): Map | undefined { +function rollingPair(values: readonly unknown[]): Map | undefined { const tokens = values.map(wholeToken); const todayAt = tokens.indexOf('today'); if (todayAt === -1 || tokens.lastIndexOf('today') !== todayAt) return undefined; - const forward = tokens - .map((tok, i) => ({ i, m: tok?.match(DAYS_FROM_NOW_RE) })) + const rolling = tokens + .map((tok, i) => ({ i, m: tok?.match(ROLLING_DAYS_RE) })) .filter((x) => x.m); - if (forward.length !== 1) return undefined; - const n = parseInt(forward[0].m![1], 10); + if (rolling.length !== 1) return undefined; + const n = parseInt(rolling[0].m![1], 10); + const otherBound = rolling[0].m![2] === 'from_now' ? '{1_day_ago}' : `{${2 * n + 1}_days_ago}`; return new Map([ [todayAt, `{${n + 1}_days_ago}`], - [forward[0].i, '{yesterday}'], + [rolling[0].i, otherBound], ]); } -/** Rewrite every forward rolling window in `value` to the window just before it. */ -function shiftForwardRollingWindows(value: T): T { +/** Rewrite every rolling window in `value` to the equal-length window just before it. */ +function shiftRollingWindows(value: T): T { if (value == null || typeof value !== 'object') return value; if (Array.isArray(value)) { - const pair = value.length === 2 ? forwardRollingPair(value) : undefined; - return value.map((v, i) => pair?.get(i) ?? shiftForwardRollingWindows(v)) as any; + const pair = value.length === 2 ? rollingPair(value) : undefined; + return value.map((v, i) => pair?.get(i) ?? shiftRollingWindows(v)) as any; } const proto = Object.getPrototypeOf(value); if (proto !== Object.prototype && proto !== null) return value; const operators = Object.keys(value as any).filter((k) => k.startsWith('$')); - const pair = forwardRollingPair(operators.map((k) => (value as any)[k])); + const pair = rollingPair(operators.map((k) => (value as any)[k])); const out: Record = {}; for (const k of Object.keys(value as any)) { const at = operators.indexOf(k); - out[k] = (at === -1 ? undefined : pair?.get(at)) ?? shiftForwardRollingWindows((value as any)[k]); + out[k] = (at === -1 ? undefined : pair?.get(at)) ?? shiftRollingWindows((value as any)[k]); } return out as any; } -/** Does `value` hold a window {@link shiftForwardRollingWindows} would move? */ -function hasForwardRollingWindow(value: unknown): boolean { +/** Does `value` hold a window {@link shiftRollingWindows} would move? */ +function hasRollingWindow(value: unknown): boolean { if (value == null || typeof value !== 'object') return false; if (Array.isArray(value)) { - return (value.length === 2 && !!forwardRollingPair(value)) || value.some(hasForwardRollingWindow); + return (value.length === 2 && !!rollingPair(value)) || value.some(hasRollingWindow); } const entries = Object.entries(value as object); const operands = entries.filter(([k]) => k.startsWith('$')).map(([, v]) => v); - return !!forwardRollingPair(operands) || entries.some(([, v]) => hasForwardRollingWindow(v)); + return !!rollingPair(operands) || entries.some(([, v]) => hasRollingWindow(v)); } -function substituteTokens(value: T, map: Record): T { +/** + * Replace every `{token}` / `${token}` in `value` with what `replace` returns + * for it, in ONE pass, so no replacement is replaced again. + */ +function substituteTokens(value: T, replace: (token: string) => string | undefined): T { if (value == null) return value; if (typeof value === 'string') { - return value.replace(/\$?\{([a-zA-Z0-9_]+)\}/g, (m, tok) => { - const target = map[tok]; - return target ? `{${target}}` : m; - }) as any; + return value.replace(/\$?\{([a-zA-Z0-9_]+)\}/g, (m, tok) => replace(tok) ?? m) as any; } - if (Array.isArray(value)) return value.map((v) => substituteTokens(v, map)) as any; + if (Array.isArray(value)) return value.map((v) => substituteTokens(v, replace)) as any; if (typeof value === 'object') { const out: Record = {}; - for (const k of Object.keys(value as any)) out[k] = substituteTokens((value as any)[k], map); + for (const k of Object.keys(value as any)) out[k] = substituteTokens((value as any)[k], replace); return out as any; } return value; @@ -188,12 +234,16 @@ function substituteTokens(value: T, map: Record): T { * Resolve a filter for a period-over-period comparison query. Dispatches on * `compareTo.kind` — the only discriminator the converged shape has: * - * - `previousYear` — re-resolve date macros against a `now` shifted back by one year. + * - `previousYear` — re-resolve date macros against a `now` shifted back by one + * year, its day clamped to the shifted month's length (29 February → 28). * - `previousPeriod` — substitute `current_*` / `today` tokens with `last_*` / `yesterday` - * before resolving. A forward window moves back first: `next_month_*` / - * `next_quarter_*` become `current_*`, and `{today}` … `{N_days_from_now}` - * becomes `{(N+1)_days_ago}` … `{yesterday}` (objectstack#30140). Each token - * is substituted once, so no window moves back twice. + * before resolving. A rolling window moves first, by its own length: + * `{today}` … `{N_days_from_now}` becomes `{(N+1)_days_ago}` … `{1_day_ago}` + * (objectstack#30140), and `{N_days_ago}` … `{today}` becomes + * `{(2N+1)_days_ago}` … `{(N+1)_days_ago}` (objectstack#30197). Then + * `next_month_*` / `next_quarter_*` become `current_*`, and `{yesterday}` / + * `last_*` become the period before them ({@link lastPeriodBefore}). Each + * token is substituted once, so no window moves back twice. */ export function shiftFilterByCompareTo( filter: T, @@ -201,10 +251,13 @@ export function shiftFilterByCompareTo( now: Date = new Date(), ): T { if (compareTo.kind === 'previousYear') { + // `new Date(2027, 1, 29)` is 1 March 2027, which would compare a February + // window with March; the day is clamped to the shifted month's last day. + const lastDayOfShiftedMonth = new Date(now.getFullYear() - 1, now.getMonth() + 1, 0).getDate(); const shifted = new Date( now.getFullYear() - 1, now.getMonth(), - now.getDate(), + Math.min(now.getDate(), lastDayOfShiftedMonth), now.getHours(), now.getMinutes(), now.getSeconds(), @@ -212,12 +265,17 @@ export function shiftFilterByCompareTo( return resolveDateMacros(filter, shifted); } // previousPeriod. The rolling pair goes first, so it claims its `{today}` - // before the generic `today` → `yesterday` swap can; the tokens it writes - // are keys of neither map. Both maps then apply in ONE pass: a second pass - // would carry `next_month_start` on through `current_month_start` to - // `last_month_start`. - const rolled = shiftForwardRollingWindows(filter); - const substituted = substituteTokens(rolled, PREVIOUS_PERIOD_TOKENS); + // before the generic `today` → `yesterday` swap can; the `{K_day(s)_ago}` + // tokens it writes are moved by nothing after it. Every other token is then + // substituted in ONE pass: a second pass would carry `next_month_start` on + // through `current_month_start` to `last_month_start`, and `today` through + // `yesterday` to `2_days_ago`. + const rolled = shiftRollingWindows(filter); + const substituted = substituteTokens(rolled, (tok) => { + const renamed = PREVIOUS_PERIOD_TOKENS[tok]; + if (renamed) return `{${renamed}}`; + return lastPeriodBefore(tok, now); + }); return resolveDateMacros(substituted, now); } @@ -231,6 +289,12 @@ export function shiftFilterByCompareTo( * equal-length days before it (`vsPreviousPeriod`) (objectstack#30140). Each * forward check sits before the backward check whose pattern also matches it * (`quarter_start` is inside `next_quarter_start`). + * + * A window that is already past (`{yesterday}`, `last_*`) compares with the + * period before it, and a backward rolling window with the equal-length days + * before it; no `dashboard.trend.*` key names "the month before last", so all + * of them read `vsPreviousPeriod` (objectstack#30197). That check comes first: + * `last_month_start` also contains `month_start`. */ export function compareToTrendLabelKey( compareTo: CompareToConfig, @@ -239,13 +303,14 @@ export function compareToTrendLabelKey( if (compareTo.kind === 'previousYear') return 'vsLastYear'; // previousPeriod — sniff the dominant token in the filter const json = filter ? JSON.stringify(filter) : ''; + if (/\{(last_(week|month|quarter|year)_(start|end)|yesterday)\}/.test(json)) return 'vsPreviousPeriod'; if (/current_year_|year_start|year_end/.test(json)) return 'vsLastYear'; if (/next_quarter_(start|end)/.test(json)) return 'vsThisQuarter'; if (/current_quarter_|quarter_start|quarter_end/.test(json)) return 'vsLastQuarter'; if (/next_month_(start|end)/.test(json)) return 'vsThisMonth'; if (/current_month_|month_start|month_end/.test(json)) return 'vsLastMonth'; if (/current_week_|week_start|week_end/.test(json)) return 'vsLastWeek'; - if (hasForwardRollingWindow(filter)) return 'vsPreviousPeriod'; + if (hasRollingWindow(filter)) return 'vsPreviousPeriod'; if (/\btoday\b/.test(json)) return 'vsYesterday'; return 'vsPreviousPeriod'; } diff --git a/ui/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.compareTo.pastPresets-30197.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.compareTo.pastPresets-30197.test.tsx new file mode 100644 index 00000000000..f0632c80143 --- /dev/null +++ b/ui/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.compareTo.pastPresets-30197.test.tsx @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#30197 — an inline KPI tile whose window is already past + * (`yesterday`, `last_month`, `last_7_days`) and which asks for + * `compareTo: { kind: 'previousPeriod' }` shows the change against the window + * just before it, labelled "vs previous period". + * + * Before the fix `yesterday` and `last_month` kept their tokens, so the + * comparison aggregate ran over the current window again and the tile painted + * a 0% change ("vs last month" for 上月); `last_7_days` (8 days through today) + * moved only its end and was labelled "vs yesterday". + * + * Same harness as `ObjectMetricWidget.compareTo.forwardPresets-30140.test.tsx`: + * the data source answers 120 for the current window, 100 for the expected + * previous window and 7 for any other, so +20% is reachable only by querying + * exactly the expected previous window. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { DATE_RANGE_PRESET_MACRO_WINDOWS } from '@objectstack/spec/data'; +// Registers `object-metric` at MODULE scope, never inside a hook +// (object-ui/no-dynamic-import-in-test-hook). +import '../index'; + +afterEach(cleanup); + +const iso = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +const today = new Date(); +const addDays = (n: number) => new Date(today.getFullYear(), today.getMonth(), today.getDate() + n); + +/** Each past preset: its own first day, and the first and last day of the window before it. */ +const CASES = [ + { + preset: 'last_month' as const, + currentFrom: iso(new Date(today.getFullYear(), today.getMonth() - 1, 1)), + previous: [iso(new Date(today.getFullYear(), today.getMonth() - 2, 1)), iso(new Date(today.getFullYear(), today.getMonth() - 1, 0))], + staleLabel: /vs last month/i, + }, + { + // A single day against the day before it, written as one equality bound. + preset: 'yesterday' as const, + currentFrom: iso(addDays(-1)), + previous: [iso(addDays(-2)), iso(addDays(-2))], + staleLabel: /vs yesterday/i, + }, + { + // Seven days ago through today, 8 days, against the 8 days before them. + preset: 'last_7_days' as const, + currentFrom: iso(addDays(-7)), + previous: [iso(addDays(-15)), iso(addDays(-8))], + staleLabel: /vs yesterday/i, + }, +]; + +describe('object-metric compareTo on an already-past preset window (#30197)', () => { + it.each(CASES)('$preset compares with the window just before it', async ({ preset, currentFrom, previous, staleLabel }) => { + const [start, end] = DATE_RANGE_PRESET_MACRO_WINDOWS[preset]; + const aggregate = vi.fn(async (_object: string, q: any) => { + const window = q?.filter?.close_date ?? {}; + if (window.$gte === currentFrom) return [{ amount_sum: 120 }]; + if (window.$gte === previous[0] && window.$lte === previous[1]) return [{ amount_sum: 100 }]; + return [{ amount_sum: 7 }]; + }); + render( + + + , + ); + + await waitFor(() => expect(aggregate).toHaveBeenCalledTimes(2)); + const windows = aggregate.mock.calls.map((c: any[]) => c[1].filter.close_date); + expect(windows).toContainEqual({ $gte: previous[0], $lte: previous[1] }); + + // 120 against 100: a non-zero change, under the window's own label. + expect(await screen.findByText('20%')).toBeInTheDocument(); + expect(await screen.findByText(/vs previous period/i)).toBeInTheDocument(); + expect(screen.queryByText(staleLabel)).not.toBeInTheDocument(); + }); +});