From fa390d3ae0cc5d7107685677da6e5ec1f367a521 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:04:33 +0000 Subject: [PATCH 1/2] fix(core): compareTo on a forward date range compares with the period just before it, not with itself previousPeriod moved only current_* / today tokens, so a dashboard window of next_month or next_quarter was compared with itself (a 0 change every time) and next_30_days moved only its start. A forward window now steps back one period: next_month_* to this month, next_quarter_* to this quarter, and a {today} .. {N_days_from_now} pair to the N + 1 days ending yesterday. The trend label names that window (vsThisMonth, vsThisQuarter, vsPreviousPeriod); the two new keys are in all ten locale packs. The 13 backward presets keep their windows and labels. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzijmF7yUSk5ga9jRkh --- .../30140-compare-to-forward-presets.md | 16 +++ .../src/utils/__tests__/compare-to.test.ts | 99 ++++++++++++++ ...hboard-filters.presetWindows-29116.test.ts | 115 +++++++++++++++-- ui/packages/core/src/utils/compare-to.ts | 122 +++++++++++++++++- ui/packages/i18n/src/locales/ar.ts | 2 + ui/packages/i18n/src/locales/de.ts | 2 + ui/packages/i18n/src/locales/en.ts | 2 + ui/packages/i18n/src/locales/es.ts | 2 + ui/packages/i18n/src/locales/fr.ts | 2 + ui/packages/i18n/src/locales/ja.ts | 2 + ui/packages/i18n/src/locales/ko.ts | 2 + ui/packages/i18n/src/locales/pt.ts | 2 + ui/packages/i18n/src/locales/ru.ts | 2 + ui/packages/i18n/src/locales/zh.ts | 2 + ui/packages/plugin-charts/src/ObjectChart.tsx | 2 + ui/packages/plugin-dashboard/SKILL.md | 3 + .../plugin-dashboard/src/MetricWidget.tsx | 6 + .../src/ObjectMetricWidget.tsx | 2 + ...et.compareTo.forwardPresets-30140.test.tsx | 99 ++++++++++++++ 19 files changed, 466 insertions(+), 18 deletions(-) create mode 100644 .changeset/30140-compare-to-forward-presets.md create mode 100644 ui/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.compareTo.forwardPresets-30140.test.tsx diff --git a/.changeset/30140-compare-to-forward-presets.md b/.changeset/30140-compare-to-forward-presets.md new file mode 100644 index 00000000000..899e188ee04 --- /dev/null +++ b/.changeset/30140-compare-to-forward-presets.md @@ -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. 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 a581eefc961..fe972631944 100644 --- a/ui/packages/core/src/utils/__tests__/compare-to.test.ts +++ b/ui/packages/core/src/utils/__tests__/compare-to.test.ts @@ -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 = { @@ -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'); 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 359267ae27a..36f14839351 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 @@ -66,11 +66,56 @@ const RETIRED_PRESET_RANGES: Record = { /** 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 = { + 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 = { + 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 @@ -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), - ); }, ); diff --git a/ui/packages/core/src/utils/compare-to.ts b/ui/packages/core/src/utils/compare-to.ts index 0d4dee0b393..ceaf203f1c6 100644 --- a/ui/packages/core/src/utils/compare-to.ts +++ b/ui/packages/core/src/utils/compare-to.ts @@ -21,7 +21,10 @@ import { resolveDateMacros } from './date-macros.js'; * current one. On the inline path that is done by substituting `current_*` / * `today` date macro tokens with their `last_*` / `yesterday` counterparts * (`current_quarter_start` → `last_quarter_start`), so it works best when the - * filter is written with date macros. + * 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. * - `kind: 'previousYear'` — re-resolve the filter's date macros against a * `now` shifted back one calendar year (same week / month / quarter, one year * earlier). @@ -71,6 +74,99 @@ const CURRENT_TO_LAST_TOKENS: Record = { today: 'yesterday', }; +/** + * The forward calendar tokens a dashboard date-range preset emits + * (`next_month`, `next_quarter`), mapped to the period just before them: the + * 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. + */ +const NEXT_TO_CURRENT_TOKENS: Record = { + next_month_start: 'current_month_start', + next_month_end: 'current_month_end', + next_quarter_start: 'current_quarter_start', + next_quarter_end: 'current_quarter_end', +}; + +/** Every token `previousPeriod` moves back one period, backward and forward. */ +const PREVIOUS_PERIOD_TOKENS: Record = { + ...CURRENT_TO_LAST_TOKENS, + ...NEXT_TO_CURRENT_TOKENS, +}; + +const WHOLE_TOKEN_RE = /^\$?\{([a-zA-Z0-9_]+)\}$/; +const DAYS_FROM_NOW_RE = /^(\d+)_days_from_now$/; + +/** The bare token a string is made of, or undefined when it is anything else. */ +function wholeToken(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + return value.match(WHOLE_TOKEN_RE)?.[1]; +} + +/** + * 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. + * + * 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. + */ +function forwardRollingPair(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) })) + .filter((x) => x.m); + if (forward.length !== 1) return undefined; + const n = parseInt(forward[0].m![1], 10); + return new Map([ + [todayAt, `{${n + 1}_days_ago}`], + [forward[0].i, '{yesterday}'], + ]); +} + +/** Rewrite every forward rolling window in `value` to the window just before it. */ +function shiftForwardRollingWindows(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 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 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]); + } + return out as any; +} + +/** Does `value` hold a window {@link shiftForwardRollingWindows} would move? */ +function hasForwardRollingWindow(value: unknown): boolean { + if (value == null || typeof value !== 'object') return false; + if (Array.isArray(value)) { + return (value.length === 2 && !!forwardRollingPair(value)) || value.some(hasForwardRollingWindow); + } + 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)); +} + function substituteTokens(value: T, map: Record): T { if (value == null) return value; if (typeof value === 'string') { @@ -94,7 +190,10 @@ function substituteTokens(value: T, map: Record): T { * * - `previousYear` — re-resolve date macros against a `now` shifted back by one year. * - `previousPeriod` — substitute `current_*` / `today` tokens with `last_*` / `yesterday` - * before resolving. + * 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. */ export function shiftFilterByCompareTo( filter: T, @@ -112,14 +211,26 @@ export function shiftFilterByCompareTo( ); return resolveDateMacros(filter, shifted); } - // previousPeriod - const substituted = substituteTokens(filter, CURRENT_TO_LAST_TOKENS); + // 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); return resolveDateMacros(substituted, now); } /** * Derive a translation key suffix (under `dashboard.trend.*`) describing the * comparison window. Used by metric widgets to label the trend delta. + * + * The key names the window {@link shiftFilterByCompareTo} really compares + * with. A forward month or quarter compares with the CURRENT one + * (`vsThisMonth` / `vsThisQuarter`), and a forward rolling window with the + * 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`). */ export function compareToTrendLabelKey( compareTo: CompareToConfig, @@ -129,9 +240,12 @@ export function compareToTrendLabelKey( // previousPeriod — sniff the dominant token in the filter const json = filter ? JSON.stringify(filter) : ''; 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 (/\btoday\b/.test(json)) return 'vsYesterday'; return 'vsPreviousPeriod'; } diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index ad263803771..a5bff7714db 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1844,6 +1844,8 @@ const ar = { vsLastYear: "مقابل العام الماضي", vsYesterday: "مقابل أمس", vsPreviousPeriod: "مقابل الفترة السابقة", + vsThisMonth: "مقابل هذا الشهر", + vsThisQuarter: "مقابل هذا الربع", }, }, appDesigner: { diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index ac6ec207740..1851e09dce5 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1637,6 +1637,8 @@ const de = { vsLastYear: "vs. letztes Jahr", vsYesterday: "vs. gestern", vsPreviousPeriod: "vs. vorherige Periode", + vsThisMonth: "vs. diesen Monat", + vsThisQuarter: "vs. dieses Quartal", }, }, appDesigner: { diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index ac820631596..3de05c455df 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2003,6 +2003,8 @@ const en = { vsLastYear: 'vs last year', vsYesterday: 'vs yesterday', vsPreviousPeriod: 'vs previous period', + vsThisMonth: 'vs this month', + vsThisQuarter: 'vs this quarter', }, filters: { label: 'Dashboard filters', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index d14805d8e74..2e9d460f9a9 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1691,6 +1691,8 @@ const es = { vsLastYear: "vs. año anterior", vsYesterday: "vs. ayer", vsPreviousPeriod: "vs. período anterior", + vsThisMonth: "vs. este mes", + vsThisQuarter: "vs. este trimestre", }, }, appDesigner: { diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 06d0d995c21..f80befa0912 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1687,6 +1687,8 @@ const fr = { vsLastYear: "vs. année dernière", vsYesterday: "vs. hier", vsPreviousPeriod: "vs. période précédente", + vsThisMonth: "vs. ce mois-ci", + vsThisQuarter: "vs. ce trimestre", }, }, appDesigner: { diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 3bbd36cdaad..8e4b26236da 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1637,6 +1637,8 @@ const ja = { vsLastYear: "前年比", vsYesterday: "昨日比", vsPreviousPeriod: "前期比", + vsThisMonth: "今月比", + vsThisQuarter: "今四半期比", }, }, appDesigner: { diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index cfe5c0c0e38..a6b8518d0ec 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1637,6 +1637,8 @@ const ko = { vsLastYear: "전년 대비", vsYesterday: "어제 대비", vsPreviousPeriod: "이전 기간 대비", + vsThisMonth: "이번 달 대비", + vsThisQuarter: "이번 분기 대비", }, }, appDesigner: { diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index b14c329ddf2..cb6c8735a5d 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1686,6 +1686,8 @@ const pt = { vsLastYear: "vs. ano anterior", vsYesterday: "vs. ontem", vsPreviousPeriod: "vs. período anterior", + vsThisMonth: "vs. este mês", + vsThisQuarter: "vs. este trimestre", }, }, appDesigner: { diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 8aedabf4246..2d33eec4ccd 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1745,6 +1745,8 @@ const ru = { vsLastYear: "пред. год", vsYesterday: "вчера", vsPreviousPeriod: "пред. период", + vsThisMonth: "тек. месяц", + vsThisQuarter: "тек. квартал", }, }, appDesigner: { diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 58001bf4779..a3a40c542b6 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1674,6 +1674,8 @@ const zh = { vsLastYear: '较去年', vsYesterday: '较昨日', vsPreviousPeriod: '较上期', + vsThisMonth: '较本月', + vsThisQuarter: '较本季度', }, filters: { label: '仪表盘过滤器', diff --git a/ui/packages/plugin-charts/src/ObjectChart.tsx b/ui/packages/plugin-charts/src/ObjectChart.tsx index 091f5a1af8b..b3f61d16443 100644 --- a/ui/packages/plugin-charts/src/ObjectChart.tsx +++ b/ui/packages/plugin-charts/src/ObjectChart.tsx @@ -1274,6 +1274,8 @@ export const ObjectChart = (props: ObjectChartProps) => { vsLastYear: 'Previous year', vsYesterday: 'Yesterday', vsPreviousPeriod: 'Previous period', + vsThisMonth: 'This month', + vsThisQuarter: 'This quarter', }; const labelKey = compareToTrendLabelKey(compareToConfig!, schema.filter); const friendlyLabel = labelMap[labelKey] || 'Previous period'; diff --git a/ui/packages/plugin-dashboard/SKILL.md b/ui/packages/plugin-dashboard/SKILL.md index 09d8f64049f..64d7f435b6f 100644 --- a/ui/packages/plugin-dashboard/SKILL.md +++ b/ui/packages/plugin-dashboard/SKILL.md @@ -93,9 +93,12 @@ without per-card configuration: | Filter contains | Translation key | |---|---| | `{current_year_*}` / `{year_*}` | `dashboard.trend.vsLastYear` | +| `{next_quarter_*}` | `dashboard.trend.vsThisQuarter` | | `{current_quarter_*}` / `{quarter_*}` | `dashboard.trend.vsLastQuarter` | +| `{next_month_*}` | `dashboard.trend.vsThisMonth` | | `{current_month_*}` / `{month_*}` | `dashboard.trend.vsLastMonth` | | `{current_week_*}` / `{week_*}` | `dashboard.trend.vsLastWeek` | +| `{today}` … `{N_days_from_now}` | `dashboard.trend.vsPreviousPeriod` | | `{today}` | `dashboard.trend.vsYesterday` | | anything else | `dashboard.trend.vsPreviousPeriod` | diff --git a/ui/packages/plugin-dashboard/src/MetricWidget.tsx b/ui/packages/plugin-dashboard/src/MetricWidget.tsx index 3074987730f..5b035158b64 100644 --- a/ui/packages/plugin-dashboard/src/MetricWidget.tsx +++ b/ui/packages/plugin-dashboard/src/MetricWidget.tsx @@ -30,6 +30,8 @@ const TREND_LABEL_DEFAULTS: Record = { 'dashboard.trend.vsLastYear': 'vs last year', 'dashboard.trend.vsYesterday': 'vs yesterday', 'dashboard.trend.vsPreviousPeriod': 'vs previous period', + 'dashboard.trend.vsThisMonth': 'vs this month', + 'dashboard.trend.vsThisQuarter': 'vs this quarter', }; const useTrendT = createSafeTranslation(TREND_LABEL_DEFAULTS, 'dashboard.trend.vsLastQuarter'); @@ -54,6 +56,10 @@ function trendLabelKey(label: string): string | undefined { case 'vs last year': case 'vs previous year': return 'dashboard.trend.vsLastYear'; + case 'vs this month': + return 'dashboard.trend.vsThisMonth'; + case 'vs this quarter': + return 'dashboard.trend.vsThisQuarter'; case 'vs yesterday': return 'dashboard.trend.vsYesterday'; case 'vs previous period': diff --git a/ui/packages/plugin-dashboard/src/ObjectMetricWidget.tsx b/ui/packages/plugin-dashboard/src/ObjectMetricWidget.tsx index 71e865e8f30..600d876ad44 100644 --- a/ui/packages/plugin-dashboard/src/ObjectMetricWidget.tsx +++ b/ui/packages/plugin-dashboard/src/ObjectMetricWidget.tsx @@ -696,6 +696,8 @@ export const ObjectMetricWidget: React.FC = ({ vsLastQuarter: 'vs last quarter', vsLastYear: 'vs last year', vsYesterday: 'vs yesterday', + vsThisMonth: 'vs this month', + vsThisQuarter: 'vs this quarter', }; const labelText = tProp ? tProp(fullKey, defaults[labelKey] || labelKey) : (defaults[labelKey] || labelKey); return { value: delta.value, direction: delta.direction, label: labelText }; diff --git a/ui/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.compareTo.forwardPresets-30140.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.compareTo.forwardPresets-30140.test.tsx new file mode 100644 index 00000000000..b4d25825ad9 --- /dev/null +++ b/ui/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.compareTo.forwardPresets-30140.test.tsx @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#30140 — an inline KPI tile whose window is one of the forward + * dashboard presets (`next_month`, `next_quarter`, `next_30_days`) and which + * asks for `compareTo: { kind: 'previousPeriod' }` shows the change against the + * window just before it, under a label that names that window. + * + * Before the fix the comparison aggregate ran over the current window again + * (`next_*` tokens were never substituted), so the tile painted a 0% change + * labelled "vs last month" / "vs last quarter"; the rolling window moved only + * its start and was labelled "vs yesterday". + * + * The data source answers 120 for the current window, 100 for the expected + * previous window, and 7 for any other window, so the tile can only show +20% + * by querying exactly the expected previous window. + * + * Mounted through the registered `object-metric` block, the path an author + * reaches. The filter is each preset's own window, read from the spec table + * the dashboard filter bar lowers presets through. + */ + +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); +const quarterStart = (offset: number) => + new Date(today.getFullYear(), Math.floor(today.getMonth() / 3) * 3 + offset * 3, 1); + +/** Each forward preset: its own first day, and the first and last day of the window before it. */ +const CASES = [ + { + preset: 'next_month' as const, + currentFrom: iso(new Date(today.getFullYear(), today.getMonth() + 1, 1)), + previous: [iso(new Date(today.getFullYear(), today.getMonth(), 1)), iso(new Date(today.getFullYear(), today.getMonth() + 1, 0))], + label: /vs this month/i, + staleLabel: /vs last month/i, + }, + { + preset: 'next_quarter' as const, + currentFrom: iso(quarterStart(1)), + previous: [iso(quarterStart(0)), iso(new Date(quarterStart(1).getFullYear(), quarterStart(1).getMonth(), 0))], + label: /vs this quarter/i, + staleLabel: /vs last quarter/i, + }, + { + // Today and the 30 days after it, 31 days, against the 31 days that end yesterday. + preset: 'next_30_days' as const, + currentFrom: iso(today), + previous: [iso(addDays(-31)), iso(addDays(-1))], + label: /vs previous period/i, + staleLabel: /vs yesterday/i, + }, +]; + +describe('object-metric compareTo on a forward preset window (#30140)', () => { + it.each(CASES)('$preset compares with the window just before it', async ({ preset, currentFrom, previous, label, 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(label)).toBeInTheDocument(); + expect(screen.queryByText(staleLabel)).not.toBeInTheDocument(); + }); +}); From 84032ed3c6f5d9c4b84de3192b0db9ebd0e69a78 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 02:30:37 +0000 Subject: [PATCH 2/2] fix(core): compareTo on an already-past date range compares with the period before it, and previousYear from 29 February stays in February previousPeriod never moved {yesterday} or the last_* tokens, so yesterday, last week, last month, last quarter and last year compared the window with itself, and {N_days_ago} .. {today} moved only its end. The past tokens now compare with the period before them (computed from the resolved last_* start, since no token names two periods back; {yesterday} becomes {2_days_ago}), and a backward rolling window moves back by its own N + 1 days. previousYear clamps the shifted day to the month's length, so a 29 February window compares with February, not March. These comparisons are labelled vsPreviousPeriod. today, this_* and the forward presets are unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzijmF7yUSk5ga9jRkh --- .changeset/30197-compare-to-past-presets.md | 13 ++ .../src/utils/__tests__/compare-to.test.ts | 73 ++++++++ ...hboard-filters.presetWindows-29116.test.ts | 50 +++-- ui/packages/core/src/utils/compare-to.ts | 177 ++++++++++++------ ...idget.compareTo.pastPresets-30197.test.tsx | 92 +++++++++ 5 files changed, 331 insertions(+), 74 deletions(-) create mode 100644 .changeset/30197-compare-to-past-presets.md create mode 100644 ui/packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.compareTo.pastPresets-30197.test.tsx 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(); + }); +});