diff --git a/.changeset/30134-client-next-week-year-end.md b/.changeset/30134-client-next-week-year-end.md new file mode 100644 index 00000000000..2fe0cc94cda --- /dev/null +++ b/.changeset/30134-client-next-week-year-end.md @@ -0,0 +1,10 @@ +--- +'@object-ui/core': patch +--- + +fix(core): the client date-macro resolver answers `{next_week_end}` and `{next_year_end}` + +Clause-②: no +Metadata-protocol: none + +`resolveDateMacros` now resolves `{next_week_end}` (Sunday of next week, Monday-start weeks) and `{next_year_end}` (Dec 31 of next year), the two fixed tokens `@objectstack/spec`'s `DATE_MACRO_TOKENS` declared that the Console passed through as raw text. A test now reads `DATE_MACRO_TOKENS` from the spec and fails on any token the client leaves unresolved, so the two lists cannot drift again. diff --git a/ui/packages/core/src/utils/__tests__/date-macros.specTokens-30134.test.ts b/ui/packages/core/src/utils/__tests__/date-macros.specTokens-30134.test.ts new file mode 100644 index 00000000000..951eb94078c --- /dev/null +++ b/ui/packages/core/src/utils/__tests__/date-macros.specTokens-30134.test.ts @@ -0,0 +1,46 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { DATE_MACRO_TOKENS } from '@objectstack/spec/data'; +import { resolveDateMacros } from '../date-macros'; + +/** + * The client resolver answers every fixed token the spec declares + * (objectstack#30134). The list is read from the spec, never copied, so a + * token added there without a resolver entry here fails this file. + */ +describe('resolveDateMacros covers the spec vocabulary (objectstack#30134)', () => { + // Monday 2026-05-25, local noon. + const now = new Date(2026, 4, 25, 12, 0, 0); + + it.each(DATE_MACRO_TOKENS.map((t) => [t]))('{%s} resolves to a day or an instant', (token) => { + const out = resolveDateMacros(`{${token}}`, now); + expect(out).not.toContain('{'); + expect(out).toMatch(/^\d{4}-\d{2}-\d{2}(T|$)/); + }); + + it('{next_week_end} is the Sunday of next week (Monday-start weeks)', () => { + // From a Monday. + expect(resolveDateMacros('{next_week_end}', now)).toBe('2026-06-07'); + // From a Sunday: still the week after the one that Sunday closes. + expect(resolveDateMacros('{next_week_end}', new Date(2026, 4, 31, 12))).toBe('2026-06-07'); + // Across a year boundary: Wednesday 2026-12-30 → next week is Jan 4–10, 2027. + const dec30 = new Date(2026, 11, 30, 12); + expect(resolveDateMacros('{next_week_start}', dec30)).toBe('2027-01-04'); + expect(resolveDateMacros('{next_week_end}', dec30)).toBe('2027-01-10'); + }); + + it('{next_year_end} is Dec 31 of next year', () => { + expect(resolveDateMacros('{next_year_end}', now)).toBe('2027-12-31'); + // On Dec 31 itself the answer is a full year out, not today. + expect(resolveDateMacros('{next_year_end}', new Date(2026, 11, 31, 12))).toBe('2027-12-31'); + // On Jan 1 the year has rolled: next year is 2028. + expect(resolveDateMacros('{next_year_end}', new Date(2027, 0, 1, 12))).toBe('2028-12-31'); + }); +}); diff --git a/ui/packages/core/src/utils/date-macros.ts b/ui/packages/core/src/utils/date-macros.ts index 79f2da54aee..39ecf578b4f 100644 --- a/ui/packages/core/src/utils/date-macros.ts +++ b/ui/packages/core/src/utils/date-macros.ts @@ -27,8 +27,10 @@ import { getDisplayTimeZone } from './date-display.js'; * - last_month_start / last_month_end * - last_quarter_start / last_quarter_end * - last_year_start / last_year_end - * - next_week_start / next_month_start / next_quarter_start / next_year_start - * - next_month_end / next_quarter_end + * - next_week_start / next_week_end + * - next_month_start / next_month_end + * - next_quarter_start / next_quarter_end + * - next_year_start / next_year_end * * Parameterised tokens (match `N` as any positive integer): * - {N_minutes_ago} / {N_minutes_from_now} (returns full ISO timestamp) @@ -39,9 +41,9 @@ import { getDisplayTimeZone } from './date-display.js'; * - {N_years_ago} / {N_years_from_now} * * The canonical grammar is also published as part of the platform contract at - * `@objectstack/spec` → `DATE_MACRO_TOKENS` / `DATE_MACRO_PARAM_RE`. Keep this - * file in sync with the spec; the duplication is temporary until the next - * coordinated `@objectstack/spec` release. + * `@objectstack/spec` → `DATE_MACRO_TOKENS` / `DATE_MACRO_PARAM_RE`. Every + * fixed token there must resolve here; `date-macros.specTokens-30134.test.ts` + * reads the spec list and fails on any token this resolver leaves unresolved. * * Walks any plain-object / array structure recursively. Non-string values * (and unknown tokens) are passed through untouched. @@ -128,11 +130,13 @@ export function resolveDateMacros(filter: T, now: Date = new Date()): T last_year_end: () => isoDate(new Date(today.getFullYear() - 1, 11, 31)), next_week_start: () => isoDate(addDays(startOfWeek(today), 7)), + next_week_end: () => isoDate(addDays(startOfWeek(today), 13)), next_month_start: () => isoDate(addMonths(startOfMonth(today), 1)), next_month_end: () => isoDate(endOfMonth(addMonths(startOfMonth(today), 1))), next_quarter_start: () => isoDate(addMonths(startOfQuarter(today), 3)), next_quarter_end: () => isoDate(endOfQuarter(addMonths(startOfQuarter(today), 3))), next_year_start: () => isoDate(new Date(today.getFullYear() + 1, 0, 1)), + next_year_end: () => isoDate(new Date(today.getFullYear() + 1, 11, 31)), }; // Aliases: bare {week_start} etc. mean the current period.