Skip to content

Commit 31260ca

Browse files
fix(core): compareTo on an already-past date range compares with the period before it, and previousYear from 29 February stays in February (#30246)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB727BBWue3wvnPrGZkDgyYY)_ Fixes #30197 ## 维护者速读 - 改了什么:指标块开启「与上一周期对比」时,「昨天」「上周」「上月」「上季度」「去年」不再拿窗口和自己比,改为对比再往前一期(前天、上上周、上上月……);「最近 7/30/90 天」改为对比之前等长的那段日子;「与去年同期对比」在 2 月 29 日不再跑到 3 月。这些对比的文字统一为「较上期」。 - 为什么改:#30196 的验收备注发现的老问题:后向预设的对比涨跌永远是 0%,最近 N 天的对比窗口与本期重叠,闰日的去年同期错月。 - 风险与回滚:只改 `compare-to.ts` 一个文件的逻辑,不动规范、不动服务端。「今天」「本周/本月/本季度/今年」和三个前向预设的环比窗口逐字节不变;变化的只有上述后向预设,以及 2 月 29 日那天的去年同期。回滚即 revert。 - 先看:① `compare-to.ts` 的 `lastPeriodBefore`(规范没有「前两期」的宏,窗口是算出来的);② 同文件 `rollingPair` 的后向分支(N+1 天整体后移);③ `previousYear` 的日期夹取。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/core/src/utils/__tests__/dashboard-filters.presetWindows-29116.test.ts`,预期 16 个预设全部通过,每个预设的上一期都在本期开始前一天结束。 - 需要你做的:无,席位落地。 Clause-②: no Metadata-protocol: none ## What changes - **`@object-ui/core` `shiftFilterByCompareTo`, `kind: 'previousPeriod'`.** - `{yesterday}` and `last_week_*` / `last_month_*` / `last_quarter_*` / `last_year_*` were never substituted, so the `yesterday`, `last_week`, `last_month`, `last_quarter` and `last_year` presets compared the window with itself. Now `{yesterday}` becomes `{2_days_ago}`, and each `last_*` token becomes the period before last. - **How "two periods back" is spelled, measured.** `DATE_MACRO_TOKENS` (`packages/spec/src/data/date-macros.zod.ts`) stops at `last_*`, and the parameterised grammar (`{N_weeks_ago}`, `{N_months_ago}`) counts from today, not from a period's first day, so no declared token names the month before last. `lastPeriodBefore` therefore computes the day: it resolves the `last_*` START through `resolveDateMacros` (the display zone, like every other token), moves it back one week / month / quarter / year with UTC calendar arithmetic, and takes the day before that `last_*` start as the end. The result is an ISO day, which `resolveDateMacros` passes through. No token is invented. - **Rolling windows.** `rollingPair` (was `forwardRollingPair`) now also matches `{N_days_ago}` with `{today}`. That window is N + 1 days (`last_7_days` at 2026-07-15 is 07-08..07-15, measured), and moves back by N + 1: `{(2N+1)_days_ago}` … `{(N+1)_days_ago}`. It used to become `{N_days_ago}` … `{yesterday}`, N days overlapping the current window. The forward branch now writes `{1_day_ago}` instead of `{yesterday}`, because `{yesterday}` is now itself moved; the resolved window is unchanged. - All substitutions still happen in ONE pass (`substituteTokens` now takes a replacer), so `today` → `yesterday` is never carried on to `2_days_ago`. - **`kind: 'previousYear'`** built `new Date(y - 1, 1, 29)`, which is 1 March, so every window seen from 2028-02-29 was re-resolved at 2027-03-01 (`this_month` compared with March 2027). The day is clamped to the shifted month's length, 28 February. - **`compareToTrendLabelKey`**: a `{yesterday}` or `last_*` token, and a backward rolling window, read `vsPreviousPeriod` (they read `vsYesterday`, `vsLastWeek`, `vsLastMonth`, `vsLastQuarter`, `vsLastYear`). No existing `dashboard.trend.*` key names "the month before last", and `vsPreviousPeriod` ("vs previous period", 较上期) is exact for each, so no new key, no locale or fallback change. The check runs first, since `last_month_start` also contains `month_start`. - **Docblock correction in `NEXT_TO_CURRENT_TOKENS`** (my #30196 text): it said `next_week_*` / `next_year_*` have no `_end` in the macro vocabulary. The spec declares `next_week_end` and `next_year_end`; it is this package's resolver (`date-macros.ts`) that lacks them. The comment now says so; the mapping is unchanged. ## Why #30197, filed from #30196's Acceptance notes. Governing text: the `compare-to.ts` docblock, "`kind: 'previousPeriod'` — the equal-length window immediately before the current one", and `previousYear` = "same week / month / quarter, one year earlier". No spec change, no new `compareTo` kind, no executor change. ## Tests - `dashboard-filters.presetWindows-29116.test.ts`: the compareTo case pins all 16 presets at 2026-07-15 (both kinds and the label); the five `last_*` / `yesterday` rows and the three `last_N_days` rows move, the eight others keep #30196's values. New for every preset on all six reference dates: the previous window ends the day before the current window starts, with the same day count for a day, a week or a rolling window, and from the 1st for a month, quarter or year. New for every preset: `previousYear` from 2028-02-29 equals resolving the filter at 2027-02-28. - `compare-to.test.ts`: yesterday, last week, last month (also from 2026-03-31, February then January), last quarter and last year across a year boundary, `{7_days_ago}`…`{today}` and a `$between` `{30_days_ago}`…`{today}`, a mixed `last_month_start`…`current_month_end` window moved bound by bound, the leap-day `previousYear` case, and the labels (a lone `{today}` still reads `vsYesterday`). - `ObjectMetricWidget.compareTo.pastPresets-30197.test.tsx` (rendered, the registered `object-metric` block, the #30196 harness): `last_month` (上月), `yesterday` and `last_7_days` each show `20%` against exactly the expected previous window, under "vs previous period", and not the stale label. ## Acceptance notes - **CONTROL, measured outside the tests.** The `tsx` probe of 16 presets × 8 reference dates × 2 kinds plus labels, `main` `ff12aed3` against this branch. Unchanged: `this_quarter`, `this_year`, `next_quarter` in full; `today`, `this_week`, `this_month`, `next_month`, `next_30_days` in every row except `previousYear` at 2028-02-29 (the leap-day fix; e.g. `this_month` 2027-03-01..03-31 → 2027-02-01..02-28). Changed as intended: `yesterday`, `last_week`, `last_month`, `last_quarter`, `last_year`, `last_7_days`, `last_30_days`, `last_90_days` (previousPeriod rows and label, plus their 2028-02-29 previousYear row where it moves). - **Inline and executor now agree on the rolling windows.** `last_30_days` at 2026-07-15: this branch 2026-05-15..06-14; the executor (`shiftRange`, measured on #30140) 2026-05-15..06-14. On calendar presets the two still differ by design: the executor counts days, the inline path steps whole periods. - Out of scope, unchanged: the client resolver lacks `next_week_end` / `next_year_end`, which the spec declares (see the docblock correction above). ## Verification <details><summary>Suites, type-check, lint, ablations</summary> - Under `os-verify-lock`: `pnpm --dir ui exec vitest run --maxWorkers=3 packages/core/` exit 0 (232 files, 4,321 passed, 27 skipped); `packages/plugin-dashboard/` exit 0 (202 files, 1,928 passed, 6 skipped). - `pnpm run type-check` in core and plugin-dashboard: exit 0. - `npx eslint` on the touched files: 0 errors (warnings only, `no-explicit-any`, as in the surrounding code). - Ablations (`scripts/ablation-replace.mjs`, each red then restored to HEAD): - past tokens (`lastPeriodBefore` not called): 12 failed, covering the five past-token pins, the five 29116 rows, and the rendered `last_month` and `yesterday` rows; - backward rolling (`ROLLING_DAYS_RE` limited to `from_now`): 6 failed (the rolling pin, the label pin, the three `last_N_days` rows, the rendered `last_7_days` row); - leap day (clamp removed): 12 failed (the leap pin and the 11 presets whose 2028-02-29 `previousYear` window moves); - label (past-token check removed): 6 failed. - Derived gates (`dispatch-gates --commands`, 35): see the report on #30197. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; lint passes on the touched files. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 section excepted). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzijmF7yUSk5ga9jRkh --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0f3ee58 commit 31260ca

5 files changed

Lines changed: 331 additions & 74 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/core': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **`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.
11+
- **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.
12+
- **`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.
13+
- **`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.

‎ui/packages/core/src/utils/__tests__/compare-to.test.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,66 @@ describe('shiftFilterByCompareTo', () => {
133133
});
134134
});
135135

136+
// objectstack#30197. A window that is already past (`{yesterday}`, `last_*`)
137+
// kept its tokens and was compared with itself; a backward rolling window
138+
// (`{N_days_ago}` … `{today}`, N + 1 days) moved only its end.
139+
describe('previousPeriod on an already-past window', () => {
140+
// Monday 2026-05-25.
141+
const shift = (filter: unknown) => shiftFilterByCompareTo(filter, { kind: 'previousPeriod' }, now) as any;
142+
143+
it('yesterday compares with the day before it', () => {
144+
expect(shift({ d: '{yesterday}' }).d).toBe('2026-05-23');
145+
});
146+
147+
it('last week compares with the week before last', () => {
148+
expect(shift({ d: { $gte: '{last_week_start}', $lte: '{last_week_end}' } }).d)
149+
.toEqual({ $gte: '2026-05-11', $lte: '2026-05-17' });
150+
});
151+
152+
it('last month compares with the month before last, whatever their lengths', () => {
153+
expect(shift({ d: { $gte: '{last_month_start}', $lte: '{last_month_end}' } }).d)
154+
.toEqual({ $gte: '2026-03-01', $lte: '2026-03-31' });
155+
// From 2026-03-31: last month is February, the one before it January.
156+
expect(
157+
shiftFilterByCompareTo(
158+
{ d: { $gte: '{last_month_start}', $lte: '{last_month_end}' } },
159+
{ kind: 'previousPeriod' },
160+
new Date(2026, 2, 31, 12, 0),
161+
).d,
162+
).toEqual({ $gte: '2026-01-01', $lte: '2026-01-31' });
163+
});
164+
165+
it('last quarter and last year compare with the ones before them, across a year boundary', () => {
166+
expect(shift({ d: { $gte: '{last_quarter_start}', $lte: '{last_quarter_end}' } }).d)
167+
.toEqual({ $gte: '2025-10-01', $lte: '2025-12-31' });
168+
expect(shift({ d: { $gte: '{last_year_start}', $lte: '{last_year_end}' } }).d)
169+
.toEqual({ $gte: '2024-01-01', $lte: '2024-12-31' });
170+
});
171+
172+
it('N days ago … today compares with the N + 1 days before it', () => {
173+
// Current window: 2026-05-18 .. 2026-05-25, 8 days.
174+
expect(shift({ d: { $gte: '{7_days_ago}', $lte: '{today}' } }).d)
175+
.toEqual({ $gte: '2026-05-10', $lte: '2026-05-17' });
176+
expect(shift({ d: { $between: ['{30_days_ago}', '{today}'] } }).d.$between)
177+
.toEqual(['2026-03-25', '2026-04-24']);
178+
});
179+
180+
it('moves a mixed window bound by bound, each once', () => {
181+
// last month through this month → the month before last through last month.
182+
expect(shift({ d: { $gte: '{last_month_start}', $lte: '{current_month_end}' } }).d)
183+
.toEqual({ $gte: '2026-03-01', $lte: '2026-04-30' });
184+
});
185+
});
186+
136187
describe('previousYear', () => {
188+
it('keeps a leap-day window in February one year back (objectstack#30197)', () => {
189+
const leap = new Date(2028, 1, 29, 8, 0);
190+
expect(
191+
shiftFilterByCompareTo({ d: { $gte: '{month_start}', $lte: '{month_end}' } }, { kind: 'previousYear' }, leap).d,
192+
).toEqual({ $gte: '2027-02-01', $lte: '2027-02-28' });
193+
expect(shiftFilterByCompareTo({ d: '{today}' }, { kind: 'previousYear' }, leap).d).toBe('2027-02-28');
194+
});
195+
137196
it('re-resolves macros against now shifted back one year', () => {
138197
const filter = {
139198
d: { $gte: '{current_quarter_start}', $lte: '{current_quarter_end}' },
@@ -224,6 +283,20 @@ describe('compareToTrendLabelKey', () => {
224283
compareToTrendLabelKey({ kind: 'previousPeriod' }, { d: { $between: ['{today}', '{30_days_from_now}'] } }),
225284
).toBe('vsPreviousPeriod');
226285
});
286+
it('reads an already-past window as the previous period (objectstack#30197)', () => {
287+
for (const filter of [
288+
{ d: '{yesterday}' },
289+
{ d: { $gte: '{last_week_start}', $lte: '{last_week_end}' } },
290+
{ d: { $gte: '{last_month_start}', $lte: '{last_month_end}' } },
291+
{ d: { $gte: '{last_quarter_start}', $lte: '{last_quarter_end}' } },
292+
{ d: { $gte: '{last_year_start}', $lte: '{last_year_end}' } },
293+
{ d: { $gte: '{7_days_ago}', $lte: '{today}' } },
294+
]) {
295+
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, filter), JSON.stringify(filter)).toBe('vsPreviousPeriod');
296+
}
297+
// A lone {today} still reads yesterday.
298+
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, { d: '{today}' })).toBe('vsYesterday');
299+
});
227300
it('falls back to vsPreviousPeriod when no token matches', () => {
228301
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, { status: 'open' })).toBe('vsPreviousPeriod');
229302
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, undefined)).toBe('vsPreviousPeriod');

‎ui/packages/core/src/utils/__tests__/dashboard-filters.presetWindows-29116.test.ts‎

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -71,29 +71,36 @@ const FORWARD_PRESETS: readonly string[] = ['next_month', 'next_quarter', 'next_
7171
const RETIRED_PRESETS = [...DATE_RANGE_PRESETS].filter((p) => !FORWARD_PRESETS.includes(p));
7272

7373
/**
74-
* CONTROL for #30140: the previous-period and previous-year windows and the
75-
* trend label of every retired preset at REFERENCE_NOWS[0] (2026-07-15), read
76-
* off `compare-to.ts` at main b7075d99 before the forward fix. The fix must
77-
* leave every one of them as it was.
74+
* The previous-period and previous-year windows and the trend label of every
75+
* retired preset at REFERENCE_NOWS[0], Wednesday 2026-07-15.
76+
*
77+
* CONTROL rows (`today`, `this_*`): as read off `compare-to.ts` at main
78+
* b7075d99, before #30140, and unchanged by #30197.
79+
*
80+
* #30197 rows: an already-past window used to compare with itself
81+
* (`yesterday` with 07-14, `last_month` with June) and `last_N_days` (N + 1
82+
* days through today) with the N days that end yesterday. Each now compares
83+
* with the period just before it: 07-13; the week of 06-29; May; Q1; 2024;
84+
* and for `last_7_days` (07-08..07-15, 8 days) the 8 days 06-30..07-07.
7885
*/
7986
const RETIRED_COMPARE_AT_REFERENCE: Record<string, {
8087
label: string;
8188
previousPeriod: [string, string];
8289
previousYear: [string, string];
8390
}> = {
8491
today: { label: 'vsYesterday', previousPeriod: ['2026-07-14', '2026-07-14'], previousYear: ['2025-07-15', '2025-07-15'] },
85-
yesterday: { label: 'vsPreviousPeriod', previousPeriod: ['2026-07-14', '2026-07-14'], previousYear: ['2025-07-14', '2025-07-14'] },
92+
yesterday: { label: 'vsPreviousPeriod', previousPeriod: ['2026-07-13', '2026-07-13'], previousYear: ['2025-07-14', '2025-07-14'] },
8693
this_week: { label: 'vsLastWeek', previousPeriod: ['2026-07-06', '2026-07-12'], previousYear: ['2025-07-14', '2025-07-20'] },
87-
last_week: { label: 'vsLastWeek', previousPeriod: ['2026-07-06', '2026-07-12'], previousYear: ['2025-07-07', '2025-07-13'] },
94+
last_week: { label: 'vsPreviousPeriod', previousPeriod: ['2026-06-29', '2026-07-05'], previousYear: ['2025-07-07', '2025-07-13'] },
8895
this_month: { label: 'vsLastMonth', previousPeriod: ['2026-06-01', '2026-06-30'], previousYear: ['2025-07-01', '2025-07-31'] },
89-
last_month: { label: 'vsLastMonth', previousPeriod: ['2026-06-01', '2026-06-30'], previousYear: ['2025-06-01', '2025-06-30'] },
96+
last_month: { label: 'vsPreviousPeriod', previousPeriod: ['2026-05-01', '2026-05-31'], previousYear: ['2025-06-01', '2025-06-30'] },
9097
this_quarter: { label: 'vsLastQuarter', previousPeriod: ['2026-04-01', '2026-06-30'], previousYear: ['2025-07-01', '2025-09-30'] },
91-
last_quarter: { label: 'vsLastQuarter', previousPeriod: ['2026-04-01', '2026-06-30'], previousYear: ['2025-04-01', '2025-06-30'] },
98+
last_quarter: { label: 'vsPreviousPeriod', previousPeriod: ['2026-01-01', '2026-03-31'], previousYear: ['2025-04-01', '2025-06-30'] },
9299
this_year: { label: 'vsLastYear', previousPeriod: ['2025-01-01', '2025-12-31'], previousYear: ['2025-01-01', '2025-12-31'] },
93-
last_year: { label: 'vsLastYear', previousPeriod: ['2025-01-01', '2025-12-31'], previousYear: ['2024-01-01', '2024-12-31'] },
94-
last_7_days: { label: 'vsYesterday', previousPeriod: ['2026-07-08', '2026-07-14'], previousYear: ['2025-07-08', '2025-07-15'] },
95-
last_30_days: { label: 'vsYesterday', previousPeriod: ['2026-06-15', '2026-07-14'], previousYear: ['2025-06-15', '2025-07-15'] },
96-
last_90_days: { label: 'vsYesterday', previousPeriod: ['2026-04-16', '2026-07-14'], previousYear: ['2025-04-16', '2025-07-15'] },
100+
last_year: { label: 'vsPreviousPeriod', previousPeriod: ['2024-01-01', '2024-12-31'], previousYear: ['2024-01-01', '2024-12-31'] },
101+
last_7_days: { label: 'vsPreviousPeriod', previousPeriod: ['2026-06-30', '2026-07-07'], previousYear: ['2025-07-08', '2025-07-15'] },
102+
last_30_days: { label: 'vsPreviousPeriod', previousPeriod: ['2026-05-15', '2026-06-14'], previousYear: ['2025-06-15', '2025-07-15'] },
103+
last_90_days: { label: 'vsPreviousPeriod', previousPeriod: ['2026-01-15', '2026-04-15'], previousYear: ['2025-04-16', '2025-07-15'] },
97104
};
98105

99106
/**
@@ -233,11 +240,11 @@ describe('dashboard date presets lower through the spec window table', () => {
233240
expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, filter)).toBe(
234241
compareToTrendLabelKey({ kind: 'previousPeriod' }, retiredFilter),
235242
);
236-
return;
237243
}
238244

239-
// A forward preset's previous period ends the day before its own window
240-
// starts, on every reference date, so it never overlaps it.
245+
// Every preset's previous period ends the day before its own window
246+
// starts, on every reference date, so it never overlaps it and never is
247+
// it (#30140, #30197).
241248
for (const now of REFERENCE_NOWS) {
242249
const current = resolveDateMacros(filter, now).created_at as { $gte: string; $lte: string };
243250
const previous = shiftFilterByCompareTo(filter, { kind: 'previousPeriod' }, now).created_at as {
@@ -246,14 +253,21 @@ describe('dashboard date presets lower through the spec window table', () => {
246253
};
247254
const label = `${preset} @ ${now.toString()}`;
248255
expect(daysBetween(previous.$lte, current.$gte), label).toBe(1);
249-
if (preset === 'next_30_days') {
250-
// Rolling: the same number of days as the current window.
256+
if (/_days$|^today$|^yesterday$|_week$/.test(preset)) {
257+
// A rolling window, a day or a week: the same number of days.
251258
expect(daysBetween(previous.$gte, previous.$lte), label).toBe(daysBetween(current.$gte, current.$lte));
252259
} else {
253-
// Calendar: the whole period just before, starting on its first day.
260+
// A month, quarter or year: the whole period just before, from its first day.
254261
expect(previous.$gte.endsWith('-01'), label).toBe(true);
255262
}
256263
}
264+
265+
// previousYear from a leap day reads the year before on 28 February, so
266+
// a February window stays February (#30197).
267+
expect(
268+
shiftFilterByCompareTo(filter, { kind: 'previousYear' }, new Date(2028, 1, 29, 8, 0)),
269+
`${preset} previousYear @ 2028-02-29`,
270+
).toEqual(resolveDateMacros(filter, new Date(2027, 1, 28, 8, 0)));
257271
},
258272
);
259273

0 commit comments

Comments
 (0)