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 f02f32273cf..a8da87215d3 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1845,6 +1845,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 f2467f397f3..78cec490b95 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1638,6 +1638,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 268aa04fde8..39190c93b69 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2004,6 +2004,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 4fb819a0587..fb33b8a878b 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1692,6 +1692,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 3ad2a3a6727..c8f7cdfd318 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1688,6 +1688,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 f057fe1b7ea..55f474f6d6d 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1638,6 +1638,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 d4e3eea8bb2..563f97cf8bb 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1638,6 +1638,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 6b4a1b0eb80..926e9dcc3fa 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1687,6 +1687,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 b41c9caf7ab..515738a869a 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1746,6 +1746,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 93d62ca243e..bd67f5a6d8a 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1675,6 +1675,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(); + }); +});