From 8b9078e6e9e24bcffc6e6876a65120652c0edf0a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:10:07 +0000 Subject: [PATCH 1/8] feat(spec): add next_month, next_quarter and next_30_days date-range presets The three forward presets join DATE_RANGE_PRESETS with their inclusive last-day windows. Core resolves them half-open; the month and quarter ends re-anchor next_*_start on the day it names, so no macro token is added. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzj9os3R42oiDRwN6HZ --- .../utils/analytics-date-range-conformance.ts | 2 +- .../src/utils/analytics-date-range.test.ts | 64 ++++++++++++++++++- .../core/src/utils/analytics-date-range.ts | 47 ++++++++++++-- .../lint/src/validate-preset-comparands.ts | 2 +- .../spec/src/data/date-range-presets.test.ts | 40 +++++++++++- packages/spec/src/data/date-range-presets.ts | 25 +++++++- packages/spec/src/data/filter.zod.ts | 2 +- 7 files changed, 166 insertions(+), 16 deletions(-) diff --git a/packages/core/src/utils/analytics-date-range-conformance.ts b/packages/core/src/utils/analytics-date-range-conformance.ts index 66a956e2854..6ae0c889981 100644 --- a/packages/core/src/utils/analytics-date-range-conformance.ts +++ b/packages/core/src/utils/analytics-date-range-conformance.ts @@ -197,7 +197,7 @@ export async function analyticsDateRangeConformanceFindings( const say = (msg: string) => findings.push(`${face.name}: ${msg}`); const seenWindows = new Set(); - // ── The thirteen declared names must resolve, and resolve identically ──── + // ── Every declared name must resolve, and resolve identically ────────── for (const preset of DATE_RANGE_PRESETS) { const expected: ResolvedAnalyticsDateRange = resolveAnalyticsDateRangeString(preset, options); const got = await attempt(face, preset); diff --git a/packages/core/src/utils/analytics-date-range.test.ts b/packages/core/src/utils/analytics-date-range.test.ts index c8fbaec8403..3ec44f3498f 100644 --- a/packages/core/src/utils/analytics-date-range.test.ts +++ b/packages/core/src/utils/analytics-date-range.test.ts @@ -78,7 +78,7 @@ const SWEEP_ZONES: ReadonlyArray = [ ]; describe('#16322 — every declared preset resolves to a real window', () => { - it('resolves all thirteen, and none of them is the name of the preset', () => { + it('resolves every declared preset, and none of them is the name of the preset', () => { for (const preset of DATE_RANGE_PRESETS) { const w = resolveAnalyticsDateRangePreset(preset, { now: NOW }); expect(w.start, preset).toMatch(ISO_INSTANT); @@ -105,7 +105,7 @@ describe('#16322 — every declared preset resolves to a real window', () => { } }); - it('the thirteen windows are DISTINCT — the fallback made twelve of them identical', () => { + it('the declared windows are DISTINCT — the fallback made twelve of thirteen identical', () => { const seen = new Set( DATE_RANGE_PRESETS.map((p) => { const w = resolveAnalyticsDateRangePreset(p, { now: NOW }); @@ -134,7 +134,7 @@ describe('#16322 — the START of every window is the token the spec already pre }); describe('#16322 — the upper bound is half-open for every preset, the rolling ones included', () => { - it('all thirteen presets are endExclusive', () => { + it('every declared preset is endExclusive', () => { for (const preset of DATE_RANGE_PRESETS) { const w = resolveAnalyticsDateRangePreset(preset, { now: NOW }); expect(w.endExclusive, preset).toBe(true); @@ -192,6 +192,9 @@ describe('#16322 — the upper bound is half-open for every preset, the rolling ['last_month', 'this_month'], ['last_quarter', 'this_quarter'], ['last_year', 'this_year'], + // [#30076] The forward calendar presets continue the tiling. + ['this_month', 'next_month'], + ['this_quarter', 'next_quarter'], ]; for (const [earlier, later] of pairs) { const a = resolveAnalyticsDateRangePreset(earlier, { now: NOW }); @@ -211,6 +214,61 @@ describe('#16322 — the upper bound is half-open for every preset, the rolling }); }); +describe('#30076 — the forward presets: next_month, next_quarter, next_30_days', () => { + // `next_month` and `next_quarter` stop before the month / quarter AFTER + // next, which no token names: the end is `next_*_start` re-anchored on the + // day `next_*_start` names. Each row below is a concrete window, so the + // re-anchoring is measured against hand-stated instants, not against the + // code that computes it. The sweep in the #17341 block holds all three to + // the spec's inclusive last day as well, across every SWEEP zone and + // instant. + const at = (iso: string) => new Date(iso); + const ROWS: ReadonlyArray<{ + preset: DateRangePreset; now: string; tz?: string; start: string; end: string; why: string; + }> = [ + { preset: 'next_month', now: '2026-09-09T12:34:56.789Z', start: '2026-10-01T00:00:00.000Z', end: '2026-11-01T00:00:00.000Z', why: 'UTC, mid-month' }, + { preset: 'next_quarter', now: '2026-09-09T12:34:56.789Z', start: '2026-10-01T00:00:00.000Z', end: '2027-01-01T00:00:00.000Z', why: 'UTC, crosses the year' }, + { preset: 'next_30_days', now: '2026-09-09T12:34:56.789Z', start: '2026-09-09T00:00:00.000Z', end: '2026-10-10T00:00:00.000Z', why: 'UTC, today plus 30 days' }, + // Timezone: 20:00Z on Jan 31 is already Feb 1 in Shanghai, so "next + // month" is MARCH there and February in UTC. + { preset: 'next_month', now: '2026-01-31T20:00:00.000Z', tz: 'Asia/Shanghai', start: '2026-02-28T16:00:00.000Z', end: '2026-03-31T16:00:00.000Z', why: 'Shanghai day is already February' }, + { preset: 'next_month', now: '2026-01-31T20:00:00.000Z', start: '2026-02-01T00:00:00.000Z', end: '2026-03-01T00:00:00.000Z', why: 'UTC control, from Jan 31' }, + // DST: November opens in EDT and December in EST, an hour apart. + { preset: 'next_month', now: '2026-10-15T12:00:00.000Z', tz: 'America/New_York', start: '2026-11-01T04:00:00.000Z', end: '2026-12-01T05:00:00.000Z', why: 'New York DST ends Nov 1' }, + { preset: 'next_quarter', now: '2026-12-10T12:00:00.000Z', tz: 'America/New_York', start: '2027-01-01T05:00:00.000Z', end: '2027-04-01T04:00:00.000Z', why: 'New York, EST start, EDT end' }, + { preset: 'next_30_days', now: '2026-10-20T12:00:00.000Z', tz: 'America/New_York', start: '2026-10-20T04:00:00.000Z', end: '2026-11-20T05:00:00.000Z', why: 'New York, window crosses DST end' }, + // A non-whole-hour zone on both sides of its DST end (Apr 5 2026). + { preset: 'next_month', now: '2026-03-15T00:00:00.000Z', tz: 'Pacific/Chatham', start: '2026-03-31T10:15:00.000Z', end: '2026-04-30T11:15:00.000Z', why: 'Chatham +13:45 then +12:45' }, + ]; + + it.each(ROWS)('$preset @ $now ($why)', ({ preset, now, tz, start, end }) => { + expect(resolveAnalyticsDateRangePreset(preset, { now: at(now), timezone: tz })) + .toEqual({ start, end, endExclusive: true }); + }); + + it("next_month's and next_quarter's ends are where the month / quarter after next opens", () => { + // The re-anchoring, stated as the property it stands for: the end of + // `next_X` is the start of `next_X` as seen from inside next X. + for (const now of SWEEP_INSTANTS) { + for (const tz of SWEEP_ZONES) { + for (const preset of ['next_month', 'next_quarter'] as const) { + const w = resolveAnalyticsDateRangePreset(preset, { now, timezone: tz }); + const inside = new Date(w.start); + expect(resolveAnalyticsDateRangePreset(preset, { now: inside, timezone: tz }).start, + `${preset} @ ${tz ?? 'UTC'} @ ${now.toISOString()}`).toBe(w.end); + } + } + } + }); + + it('next_30_days counts a row five days ahead, and neither a row five days ago nor one 31 days ahead', () => { + const rows = ['2026-09-04T00:00:00.000Z', '2026-09-14T00:00:00.000Z', '2026-10-10T00:00:00.000Z']; + const w = resolveAnalyticsDateRangePreset('next_30_days', { now: NOW }); + const counted = rows.filter((r) => Date.parse(r) >= Date.parse(w.start) && Date.parse(r) < Date.parse(w.end)); + expect(counted).toEqual(['2026-09-14T00:00:00.000Z']); + }); +}); + describe('#16322 — the window is anchored on the SUPPLIED timezone, both halves', () => { // The two halves fail independently and each failure is silent (#16042): // WHICH calendar day the window is on, and WHERE that day begins. diff --git a/packages/core/src/utils/analytics-date-range.ts b/packages/core/src/utils/analytics-date-range.ts index 7901ad9f33b..82a25636fd2 100644 --- a/packages/core/src/utils/analytics-date-range.ts +++ b/packages/core/src/utils/analytics-date-range.ts @@ -57,11 +57,13 @@ * * ⚠️ {@link ResolvedAnalyticsDateRange.endExclusive} is the flag that carries * that reading to the comparison, and every preset this module resolves sets - * it: each of the thirteen ends at a day boundary the window must not contain + * it: each of them ends at a day boundary the window must not contain * ⇒ `endExclusive: true`. The three ROLLING `last_N_days` presets are no * exception — they stop before TOMORROW begins, exactly as `today` does, * because the spec's `DATE_RANGE_PRESET_MACRO_WINDOWS` closes them on - * `{today}` and this module must select the same EXTENT. An end at NOW would + * `{today}` and this module must select the same EXTENT. The forward rolling + * `next_30_days` stops before the 31st day ahead begins, for the same reason: + * the spec closes it on the whole of `{30_days_from_now}`. An end at NOW would * drop the rest of today's rows (a datetime later today) that the spec's * window, the console's lowering and an author's `$between` all count. * @@ -130,8 +132,25 @@ export interface ResolvedAnalyticsDateRange { * here came to read "the eight period presets" and stayed there after `today` * and `yesterday` were corrected to close on their own last day (#17341). */ +/** + * A window bound: a macro token, or [#30076] a macro token RE-ANCHORED on the + * day another token names. + * + * Two ends have no token of their own. `next_month` stops before the month + * after next begins, and `next_quarter` before the quarter after next; the + * period grammar stops at `next_`. The re-anchored form names that day without + * calendar arithmetic here and without widening the token vocabulary: resolve + * {@link from} as usual, then resolve {@link token} AGAIN with the reference + * day moved to the start of that day in the same zone. `{ token: + * 'next_month_start', from: 'next_month_start' }` is "next month's start, as + * seen from next month's start", which is the month after next. Every day in + * the answer is still one the one resolver computed, so its month and quarter + * rules, its Monday week and its timezone handling hold unchanged. + */ +type PresetWindowBound = string | { readonly token: string; readonly from: string }; + const PRESET_WINDOW_TOKENS: Readonly< - Record + Record > = { today: ['today', 'tomorrow'], yesterday: ['yesterday', 'today'], @@ -146,6 +165,9 @@ const PRESET_WINDOW_TOKENS: Readonly< last_7_days: ['7_days_ago', 'tomorrow'], last_30_days: ['30_days_ago', 'tomorrow'], last_90_days: ['90_days_ago', 'tomorrow'], + next_month: ['next_month_start', { token: 'next_month_start', from: 'next_month_start' }], + next_quarter: ['next_quarter_start', { token: 'next_quarter_start', from: 'next_quarter_start' }], + next_30_days: ['today', '31_days_from_now'], }; /** Options every `dateRange` resolution reads. */ @@ -163,11 +185,26 @@ export interface AnalyticsDateRangeResolutionOptions { /** That macro token's calendar day, then the instant it BEGINS in `timezone`. */ function tokenDayStart(token: string, now: Date, timezone?: string): string { + return new Date(tokenDayStartMs(token, now, timezone)).toISOString(); +} + +function tokenDayStartMs(token: string, now: Date, timezone?: string): number { const day = resolveFilterToken(token, { now, timezone }); // Every token in the table above is a calendar-day macro, so the resolver // answers `YYYY-MM-DD`. A non-string answer would mean the vocabulary moved // under this table, which the pin test catches long before a caller does. - return new Date(zonedDateStartToUtcMs(String(day), timezone)).toISOString(); + return zonedDateStartToUtcMs(String(day), timezone); +} + +/** + * One bound's instant. A re-anchored bound resolves its `from` token first and + * then its `token` with the reference instant moved to the start of that day + * in `timezone`, so the resolver reads the anchor day as "today". + */ +function boundDayStart(bound: PresetWindowBound, now: Date, timezone?: string): string { + if (typeof bound === 'string') return tokenDayStart(bound, now, timezone); + const anchor = new Date(tokenDayStartMs(bound.from, now, timezone)); + return tokenDayStart(bound.token, anchor, timezone); } /** @@ -185,7 +222,7 @@ export function resolveAnalyticsDateRangePreset( const [startToken, endToken] = PRESET_WINDOW_TOKENS[preset]; return { start: tokenDayStart(startToken, now, options.timezone), - end: tokenDayStart(endToken, now, options.timezone), + end: boundDayStart(endToken, now, options.timezone), endExclusive: true, }; } diff --git a/packages/lint/src/validate-preset-comparands.ts b/packages/lint/src/validate-preset-comparands.ts index dbf9e51d8f5..65100b76fb7 100644 --- a/packages/lint/src/validate-preset-comparands.ts +++ b/packages/lint/src/validate-preset-comparands.ts @@ -54,7 +54,7 @@ import { indexObjectGraph, recordsOf, resolveFieldPath, type ObjectGraph } from * and every alias `normalizeFilterOperator` folds onto them. An ORDERED * comparison against a declared preset name has no legitimate reading on * ANY column, so no field type is needed to refuse it. - * - **Only the 13 declared names.** A near-miss (`last_60_days`) is not this + * - **Only the declared names.** A near-miss (`last_60_days`) is not this * rule's business — on a temporal field the engine's field-typed door * catches it; judging undeclared strings here would be a guessed superset. * diff --git a/packages/spec/src/data/date-range-presets.test.ts b/packages/spec/src/data/date-range-presets.test.ts index 719f2a45b33..701b33ff034 100644 --- a/packages/spec/src/data/date-range-presets.test.ts +++ b/packages/spec/src/data/date-range-presets.test.ts @@ -12,7 +12,7 @@ import { DATE_MACRO_WRAPPED_RE, isDateMacroToken } from './date-macros.zod'; import { nextUtcCalendarDay } from './calendar-day'; describe('date-range preset vocabulary — one source of truth, read by both the UI and the data side', () => { - it('declares exactly the thirteen shipped preset names, in filter-bar order', () => { + it('declares exactly the shipped preset names, in filter-bar order', () => { // Pinned literally: this list is a published vocabulary (dashboard // defaultRange / date-filter defaultValue accept it, filter comparands // refuse it), so a member appearing or vanishing must be a loud diff here, @@ -20,10 +20,11 @@ describe('date-range preset vocabulary — one source of truth, read by both the expect([...DATE_RANGE_PRESETS]).toEqual([ 'today', 'yesterday', 'this_week', 'last_week', - 'this_month', 'last_month', - 'this_quarter', 'last_quarter', + 'this_month', 'last_month', 'next_month', + 'this_quarter', 'last_quarter', 'next_quarter', 'this_year', 'last_year', 'last_7_days', 'last_30_days', 'last_90_days', + 'next_30_days', ]); }); @@ -63,6 +64,15 @@ describe('date-range preset vocabulary — one source of truth, read by both the expect(DATE_RANGE_PRESET_MACRO_WINDOWS.last_90_days).toEqual(['{90_days_ago}', '{today}']); }); + it('the forward presets prescribe their own period, and next_30_days {today} through {30_days_from_now}', () => { + expect(DATE_RANGE_PRESET_MACRO_WINDOWS.next_month).toEqual(['{next_month_start}', '{next_month_end}']); + expect(DATE_RANGE_PRESET_MACRO_WINDOWS.next_quarter).toEqual(['{next_quarter_start}', '{next_quarter_end}']); + // The mirror of last_30_days: today plus the 30 days after it. + expect(DATE_RANGE_PRESET_MACRO_WINDOWS.next_30_days).toEqual(['{today}', '{30_days_from_now}']); + expect(bareDateRangePresetComparandMessage('next_30_days', '$lte')) + .toContain("e.g. { $between: ['{today}', '{30_days_from_now}'] } — or an ISO date"); + }); + it('the refusal message names the value, the operator, the macro fix and the ISO fallback', () => { const message = bareDateRangePresetComparandMessage('last_30_days', '$gte'); expect(message).toContain('last_30_days'); // the offending value, quoted back @@ -133,6 +143,11 @@ const TOKEN_DAY_ON_REFERENCE: Readonly> = { '{year_end}': '2026-12-31', '{last_year_start}': '2025-01-01', '{last_year_end}': '2025-12-31', + '{next_month_start}': '2026-08-01', + '{next_month_end}': '2026-08-31', + '{next_quarter_start}': '2026-10-01', + '{next_quarter_end}': '2026-12-31', + '{30_days_from_now}': '2026-08-14', }; /** @@ -161,6 +176,9 @@ const EXPECTED_WINDOW_DAYS: Readonly< last_7_days: ['2026-07-08', '2026-07-15'], last_30_days: ['2026-06-15', '2026-07-15'], last_90_days: ['2026-04-16', '2026-07-15'], + next_month: ['2026-08-01', '2026-08-31'], + next_quarter: ['2026-10-01', '2026-12-31'], + next_30_days: ['2026-07-15', '2026-08-14'], }; const DAY_MS = 86_400_000; @@ -229,6 +247,11 @@ describe('the prescribed window covers exactly the days the preset names', () => expect(prescribedDayCount('2026-07-08', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.last_7_days[1]])).toBe(8); expect(prescribedDayCount('2026-06-15', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.last_30_days[1]])).toBe(31); expect(prescribedDayCount('2026-04-16', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.last_90_days[1]])).toBe(91); + // The forward rolling window mirrors last_30_days: today and the 30 days after it. + expect(prescribedDayCount('2026-07-15', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.next_30_days[1]])).toBe(31); + // August is 31 days; Q4 is 92. + expect(prescribedDayCount('2026-08-01', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.next_month[1]])).toBe(31); + expect(prescribedDayCount('2026-10-01', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.next_quarter[1]])).toBe(92); // And the full census, so no preset can drift either. for (const preset of DATE_RANGE_PRESETS) { @@ -257,6 +280,17 @@ describe('the prescribed window covers exactly the days the preset names', () => } }); + it('next_30_days counts a row five days ahead and NOT a row five days ago', () => { + // The mirror of the measurement above: closed on both sides, so neither + // past rows nor rows past the 30th day ahead are counted. + const rows = ['2026-07-10', '2026-07-20', '2026-08-15']; // − 5, + 5, + 31 + const [start, end] = DATE_RANGE_PRESET_MACRO_WINDOWS.next_30_days; + const from = midnightUtc(TOKEN_DAY_ON_REFERENCE[start]); + const stopsBefore = midnightUtc(nextUtcCalendarDay(TOKEN_DAY_ON_REFERENCE[end]) as string); + const counted = rows.filter((day) => midnightUtc(day) >= from && midnightUtc(day) < stopsBefore); + expect(counted).toEqual(['2026-07-20']); + }); + it('the refusal prescribes a one-day window for a one-day preset', () => { const yday = bareDateRangePresetComparandMessage('yesterday', '$gte'); expect(yday).toContain("{ $between: ['{yesterday}', '{yesterday}'] }"); diff --git a/packages/spec/src/data/date-range-presets.ts b/packages/spec/src/data/date-range-presets.ts index 02f902f2ff6..d253a8f037d 100644 --- a/packages/spec/src/data/date-range-presets.ts +++ b/packages/spec/src/data/date-range-presets.ts @@ -38,14 +38,23 @@ /** * The declared preset names, in the display order the filter bar offers them. + * + * The forward presets sit inside the family they extend, after its backward + * member: `next_month` closes the month group (this, last, next) and + * `next_quarter` the quarter group, so a reader scanning the dropdown finds + * every month window in one place; `next_30_days` follows the backward rolling + * windows, after `last_90_days`, so the rolling group reads backward then + * forward. A forward member never opens a group: the backward ones are the + * windows most filters ask for, and they keep the positions they shipped in. */ export const DATE_RANGE_PRESETS = [ 'today', 'yesterday', 'this_week', 'last_week', - 'this_month', 'last_month', - 'this_quarter', 'last_quarter', + 'this_month', 'last_month', 'next_month', + 'this_quarter', 'last_quarter', 'next_quarter', 'this_year', 'last_year', 'last_7_days', 'last_30_days', 'last_90_days', + 'next_30_days', ] as const; export type DateRangePreset = (typeof DATE_RANGE_PRESETS)[number]; @@ -101,6 +110,15 @@ export function isDateRangePresetName(value: unknown): value is DateRangePreset * would count next month's rows too. ⛔ So `end` is never absent: a reader * that branches on a missing end is reading a shape this table no longer has. * + * The FORWARD presets follow the same rule. `next_month` and `next_quarter` + * are calendar presets and close on their own last day. The forward rolling + * window `next_30_days` is the mirror of `last_30_days`: it runs from the + * whole of today through `{30_days_from_now}`, so it holds today and the 30 + * days after it, as `last_30_days` holds today and the 30 days before it. It + * opens on `{today}` because a row due today is still ahead of a forecast. A + * `$lte`-only prescription for it would count every past row too, which is + * why it closes on both sides like every other entry. + * * ## Why the table exists: PRESCRIPTION * * When a preset name is refused as a bare filter comparand (#8793), the @@ -133,6 +151,9 @@ export const DATE_RANGE_PRESET_MACRO_WINDOWS: Readonly< last_7_days: ['{7_days_ago}', '{today}'], last_30_days: ['{30_days_ago}', '{today}'], last_90_days: ['{90_days_ago}', '{today}'], + next_month: ['{next_month_start}', '{next_month_end}'], + next_quarter: ['{next_quarter_start}', '{next_quarter_end}'], + next_30_days: ['{today}', '{30_days_from_now}'], }; /** diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 04cf5c84dda..6fab064a139 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1956,7 +1956,7 @@ export const FieldOperatorsSchema = lazySchema(() => z.object({ * hand; refusing them here, blind, would trade a silent zero for false * rejections. (`@objectstack/lint`'s `filter-preset-comparand` rule draws * the same boundary at the same positions.) - * - **Only the 13 DECLARED names are judged** ({@link DATE_RANGE_PRESETS}) — + * - **Only the DECLARED names are judged** ({@link DATE_RANGE_PRESETS}) — * never a guessed superset (`last_60_days` stays a plain string here; on a * temporal field the engine's own door catches it, field type in hand). * `{placeholder}` spellings are another vocabulary with its own loud refusal From 5307ce9282f201c4339e25a80c23c70171c4a847 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:12:58 +0000 Subject: [PATCH 2/8] feat(ui): label and resolve the three forward date-range presets Adds next_month, next_quarter and next_30_days labels to all ten locale packs and the filter builder's defaults, teaches the client macro resolver next_month_end and next_quarter_end, and moves the two preset pins. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzj9os3R42oiDRwN6HZ --- ...lter-builder-relative-dates-29115.test.tsx | 5 ++- .../components/src/custom/filter-builder.tsx | 3 ++ ...hboard-filters.presetWindows-29116.test.ts | 43 ++++++++++++++++--- ui/packages/core/src/utils/date-macros.ts | 3 ++ ui/packages/i18n/src/locales/ar.ts | 3 ++ ui/packages/i18n/src/locales/de.ts | 3 ++ ui/packages/i18n/src/locales/en.ts | 3 ++ ui/packages/i18n/src/locales/es.ts | 3 ++ ui/packages/i18n/src/locales/fr.ts | 3 ++ ui/packages/i18n/src/locales/ja.ts | 3 ++ ui/packages/i18n/src/locales/ko.ts | 3 ++ ui/packages/i18n/src/locales/pt.ts | 3 ++ ui/packages/i18n/src/locales/ru.ts | 3 ++ ui/packages/i18n/src/locales/zh.ts | 3 ++ 14 files changed, 78 insertions(+), 6 deletions(-) diff --git a/ui/packages/components/src/__tests__/filter-builder-relative-dates-29115.test.tsx b/ui/packages/components/src/__tests__/filter-builder-relative-dates-29115.test.tsx index 9355f67bd7f..f6ca69e3cda 100644 --- a/ui/packages/components/src/__tests__/filter-builder-relative-dates-29115.test.tsx +++ b/ui/packages/components/src/__tests__/filter-builder-relative-dates-29115.test.tsx @@ -60,13 +60,16 @@ const EN_PRESET_LABELS: Record = { last_week: 'Last week', this_month: 'This month', last_month: 'Last month', + next_month: 'Next month', this_quarter: 'This quarter', last_quarter: 'Last quarter', + next_quarter: 'Next quarter', this_year: 'This year', last_year: 'Last year', last_7_days: 'Last 7 days', last_30_days: 'Last 30 days', last_90_days: 'Last 90 days', + next_30_days: 'Next 30 days', }; function renderRow(condition: Record, wrap?: (node: React.ReactNode) => React.ReactNode) { @@ -145,7 +148,7 @@ describe('the reverse lookup reads the spec table', () => { }); describe('between on a date row offers the spec presets and stores the macro window', () => { - it('lists the 13 presets in spec order, then Custom…', async () => { + it('lists the spec presets in spec order, then Custom…', async () => { renderRow({ field: 'close_date', operator: 'between', value: [] }); const options = await openOptions(relativeTrigger('close_date')!); expect(options.map((o) => o.textContent)).toEqual([ diff --git a/ui/packages/components/src/custom/filter-builder.tsx b/ui/packages/components/src/custom/filter-builder.tsx index 3f9de72d9ee..ec954c64bd5 100644 --- a/ui/packages/components/src/custom/filter-builder.tsx +++ b/ui/packages/components/src/custom/filter-builder.tsx @@ -1214,13 +1214,16 @@ const useSafeFilterTranslation = createSafeTranslation( 'dashboard.filters.range.last_week': 'Last week', 'dashboard.filters.range.this_month': 'This month', 'dashboard.filters.range.last_month': 'Last month', + 'dashboard.filters.range.next_month': 'Next month', 'dashboard.filters.range.this_quarter': 'This quarter', 'dashboard.filters.range.last_quarter': 'Last quarter', + 'dashboard.filters.range.next_quarter': 'Next quarter', 'dashboard.filters.range.this_year': 'This year', 'dashboard.filters.range.last_year': 'Last year', 'dashboard.filters.range.last_7_days': 'Last 7 days', 'dashboard.filters.range.last_30_days': 'Last 30 days', 'dashboard.filters.range.last_90_days': 'Last 90 days', + 'dashboard.filters.range.next_30_days': 'Next 30 days', 'fields.datePicker.tomorrow': 'Tomorrow', 'filterBuilder.currencyNeedsAnd': 'A currency amount is compared within the chosen currency only when every condition must match (AND).', 'fields.currencyCode.search': 'Search currency…', 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 4a5a4e67568..6336c6f0fb9 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 @@ -64,6 +64,10 @@ const RETIRED_PRESET_RANGES: Record = { last_90_days: { from: '{90_days_ago}', to: '{today}' }, }; +/** 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']; +const RETIRED_PRESETS = [...DATE_RANGE_PRESETS].filter((p) => !FORWARD_PRESETS.includes(p)); + /** * Reference instants (local time, which is what `resolveDateMacros` reads): * a mid-quarter Wednesday, a Sunday that closes a week, a Monday that opens @@ -88,9 +92,38 @@ describe('dashboard date presets lower through the spec window table', () => { warn.mockRestore(); }); - it('covers the same thirteen presets the retired table did', () => { - expect([...DATE_RANGE_PRESETS].sort()).toEqual(Object.keys(RETIRED_PRESET_RANGES).sort()); - expect(DATE_RANGE_PRESETS).toHaveLength(13); + it('covers the thirteen presets the retired table did, plus the three forward ones', () => { + expect([...DATE_RANGE_PRESETS].filter((p) => !FORWARD_PRESETS.includes(p)).sort()) + .toEqual(Object.keys(RETIRED_PRESET_RANGES).sort()); + expect(DATE_RANGE_PRESETS).toHaveLength(16); + }); + + it.each(FORWARD_PRESETS)('%s resolves to real days ahead of today in the client resolver', (preset) => { + const condition = buildFilterCondition(dateDef, { preset }) as { $gte: string; $lte: string }; + for (const now of REFERENCE_NOWS) { + const resolved = resolveDateMacros(condition, now); + expect(resolved.$gte, `${preset} @ ${now.toString()}`).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(resolved.$lte, `${preset} @ ${now.toString()}`).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(resolved.$lte > resolved.$gte, `${preset} @ ${now.toString()}`).toBe(true); + } + // Pinned on a mid-quarter Wednesday, 2026-07-15. + const expected = { + next_month: { $gte: '2026-08-01', $lte: '2026-08-31' }, + next_quarter: { $gte: '2026-10-01', $lte: '2026-12-31' }, + next_30_days: { $gte: '2026-07-15', $lte: '2026-08-14' }, + }[preset]; + expect(resolveDateMacros(condition, REFERENCE_NOWS[0])).toEqual(expected); + }); + + it('the client resolves next_month_end and next_quarter_end across a year rollover', () => { + // Seen from a Q4 day, next quarter and next month fall in the next year. + const q4 = new Date(2026, 11, 10, 12, 0); + expect(resolveDateMacros({ $gte: '{next_quarter_start}', $lte: '{next_quarter_end}' }, q4)) + .toEqual({ $gte: '2027-01-01', $lte: '2027-03-31' }); + expect(resolveDateMacros({ $gte: '{next_month_start}', $lte: '{next_month_end}' }, q4)) + .toEqual({ $gte: '2027-01-01', $lte: '2027-01-31' }); + // A month-end day: next month is February, and 2028 is a leap year. + expect(resolveDateMacros('{next_month_end}', new Date(2028, 0, 31, 12, 0))).toBe('2028-02-29'); }); it.each([...DATE_RANGE_PRESETS])('emits the spec window for %s', (preset) => { @@ -99,7 +132,7 @@ describe('dashboard date presets lower through the spec window table', () => { expect(warn).not.toHaveBeenCalled(); }); - it.each([...DATE_RANGE_PRESETS])( + it.each(RETIRED_PRESETS)( '%s resolves to the same days under the retired {current_*} spelling', (preset) => { const retired = RETIRED_PRESET_RANGES[preset]; @@ -115,7 +148,7 @@ describe('dashboard date presets lower through the spec window table', () => { }, ); - it.each([...DATE_RANGE_PRESETS])( + it.each(RETIRED_PRESETS)( '%s keeps its previous-period window and trend label for a compareTo widget', (preset) => { const retired = RETIRED_PRESET_RANGES[preset]; diff --git a/ui/packages/core/src/utils/date-macros.ts b/ui/packages/core/src/utils/date-macros.ts index c35ddb06b52..79f2da54aee 100644 --- a/ui/packages/core/src/utils/date-macros.ts +++ b/ui/packages/core/src/utils/date-macros.ts @@ -28,6 +28,7 @@ import { getDisplayTimeZone } from './date-display.js'; * - 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 * * Parameterised tokens (match `N` as any positive integer): * - {N_minutes_ago} / {N_minutes_from_now} (returns full ISO timestamp) @@ -128,7 +129,9 @@ export function resolveDateMacros(filter: T, now: Date = new Date()): T next_week_start: () => isoDate(addDays(startOfWeek(today), 7)), 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)), }; diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 949f4cf380d..e27374dd68a 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1724,13 +1724,16 @@ const ar = { last_week: "الأسبوع الماضي", this_month: "هذا الشهر", last_month: "الشهر الماضي", + next_month: "الشهر القادم", this_quarter: "هذا الربع", last_quarter: "الربع الماضي", + next_quarter: "الربع القادم", this_year: "هذه السنة", last_year: "السنة الماضية", last_7_days: "آخر 7 أيام", last_30_days: "آخر 30 يومًا", last_90_days: "آخر 90 يومًا", + next_30_days: "الـ 30 يومًا القادمة", }, }, total: "الإجمالي", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 498384c05a8..fcb17236097 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1517,13 +1517,16 @@ const de = { last_week: "Letzte Woche", this_month: "Dieser Monat", last_month: "Letzter Monat", + next_month: "Nächster Monat", this_quarter: "Dieses Quartal", last_quarter: "Letztes Quartal", + next_quarter: "Nächstes Quartal", this_year: "Dieses Jahr", last_year: "Letztes Jahr", last_7_days: "Letzte 7 Tage", last_30_days: "Letzte 30 Tage", last_90_days: "Letzte 90 Tage", + next_30_days: "Nächste 30 Tage", }, }, total: "Gesamt", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 9032a3ad57f..6c711c0a370 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2018,13 +2018,16 @@ const en = { last_week: 'Last week', this_month: 'This month', last_month: 'Last month', + next_month: 'Next month', this_quarter: 'This quarter', last_quarter: 'Last quarter', + next_quarter: 'Next quarter', this_year: 'This year', last_year: 'Last year', last_7_days: 'Last 7 days', last_30_days: 'Last 30 days', last_90_days: 'Last 90 days', + next_30_days: 'Next 30 days', }, }, }, diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 1f93a51e87f..df361771ed5 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1571,13 +1571,16 @@ const es = { last_week: "La semana pasada", this_month: "Este mes", last_month: "El mes pasado", + next_month: "El próximo mes", this_quarter: "Este trimestre", last_quarter: "El trimestre pasado", + next_quarter: "El próximo trimestre", this_year: "Este año", last_year: "El año pasado", last_7_days: "Últimos 7 días", last_30_days: "Últimos 30 días", last_90_days: "Últimos 90 días", + next_30_days: "Próximos 30 días", }, }, total: "Total", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 17dc04d5f4d..b57f981ca1a 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1567,13 +1567,16 @@ const fr = { last_week: "La semaine dernière", this_month: "Ce mois-ci", last_month: "Le mois dernier", + next_month: "Le mois prochain", this_quarter: "Ce trimestre", last_quarter: "Le trimestre dernier", + next_quarter: "Le trimestre prochain", this_year: "Cette année", last_year: "L'année dernière", last_7_days: "7 derniers jours", last_30_days: "30 derniers jours", last_90_days: "90 derniers jours", + next_30_days: "30 prochains jours", }, }, total: "Total", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 6c6be944f80..316533bc235 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1517,13 +1517,16 @@ const ja = { last_week: "先週", this_month: "今月", last_month: "先月", + next_month: "来月", this_quarter: "今四半期", last_quarter: "前四半期", + next_quarter: "来四半期", this_year: "今年", last_year: "昨年", last_7_days: "過去 7 日間", last_30_days: "過去 30 日間", last_90_days: "過去 90 日間", + next_30_days: "今後 30 日間", }, }, total: "合計", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 4afd1e29852..695d74c09ff 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1517,13 +1517,16 @@ const ko = { last_week: "지난주", this_month: "이번 달", last_month: "지난달", + next_month: "다음 달", this_quarter: "이번 분기", last_quarter: "지난 분기", + next_quarter: "다음 분기", this_year: "올해", last_year: "작년", last_7_days: "최근 7일", last_30_days: "최근 30일", last_90_days: "최근 90일", + next_30_days: "향후 30일", }, }, total: "합계", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 09ef913b30a..7c3c0d59bad 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1566,13 +1566,16 @@ const pt = { last_week: "Semana passada", this_month: "Este mês", last_month: "Mês passado", + next_month: "Próximo mês", this_quarter: "Este trimestre", last_quarter: "Trimestre passado", + next_quarter: "Próximo trimestre", this_year: "Este ano", last_year: "Ano passado", last_7_days: "Últimos 7 dias", last_30_days: "Últimos 30 dias", last_90_days: "Últimos 90 dias", + next_30_days: "Próximos 30 dias", }, }, total: "Total", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 47c36882af5..2ff19e70b41 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1625,13 +1625,16 @@ const ru = { last_week: "Прошлая неделя", this_month: "Этот месяц", last_month: "Прошлый месяц", + next_month: "Следующий месяц", this_quarter: "Этот квартал", last_quarter: "Прошлый квартал", + next_quarter: "Следующий квартал", this_year: "Этот год", last_year: "Прошлый год", last_7_days: "Последние 7 дней", last_30_days: "Последние 30 дней", last_90_days: "Последние 90 дней", + next_30_days: "Следующие 30 дней", }, }, total: "Итого", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index f2160a14e27..079345b8134 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1689,13 +1689,16 @@ const zh = { last_week: '上周', this_month: '本月', last_month: '上月', + next_month: '下月', this_quarter: '本季度', last_quarter: '上季度', + next_quarter: '下季度', this_year: '今年', last_year: '去年', last_7_days: '近 7 天', last_30_days: '近 30 天', last_90_days: '近 90 天', + next_30_days: '未来 30 天', }, }, }, From babb0c4890bdba7b94cf3c9bf9eff4c7e8fde14b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:16:15 +0000 Subject: [PATCH 3/8] docs: list the forward date-range presets; changeset Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzj9os3R42oiDRwN6HZ --- .../30076-forward-date-range-presets.md | 21 +++++++++++++++++++ content/docs/references/api/analytics.mdx | 4 ++-- content/docs/references/data/analytics.mdx | 7 +++++-- content/docs/ui/dashboards.mdx | 5 ++++- .../platform-checklist/areas/api-backend.json | 5 ++++- 5 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 .changeset/30076-forward-date-range-presets.md diff --git a/.changeset/30076-forward-date-range-presets.md b/.changeset/30076-forward-date-range-presets.md new file mode 100644 index 00000000000..51156e0901e --- /dev/null +++ b/.changeset/30076-forward-date-range-presets.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +'@objectstack/core': minor +'@objectstack/lint': patch +'@object-ui/core': minor +'@object-ui/components': minor +'@object-ui/i18n': minor +--- + +feat(spec): three forward date-range presets: `next_month`, `next_quarter` and `next_30_days` + +Clause-②: yes (widening) +Metadata-protocol: added + +The relative dates a dashboard date filter and the filter builder offer looked only backward. A forecast asks forward: what closes next month, next quarter, in the next 30 days. Those questions had to use a custom range, whose fixed dates go stale. + +`DATE_RANGE_PRESETS` now also declares `next_month` (the whole of next month), `next_quarter` (the whole of next quarter) and `next_30_days` (today and the 30 days after it, the mirror of `last_30_days`). Every place that accepts a preset accepts them: `dashboard.dateRange.defaultRange`, a date global filter's `defaultValue`, the filter builder's `between` row and `AnalyticsQuery.timeDimensions[].dateRange`. They sit beside the family they extend in the filter bar: `next_month` after `last_month`, `next_quarter` after `last_quarter`, `next_30_days` after `last_90_days`. Their `DATE_RANGE_PRESET_MACRO_WINDOWS` rows follow the inclusive last-day convention: `['{next_month_start}', '{next_month_end}']`, `['{next_quarter_start}', '{next_quarter_end}']` and `['{today}', '{30_days_from_now}']`. A preset name written as a bare filter comparand is refused with that window as the prescription, as for the other presets. + +`@objectstack/core`'s `resolveAnalyticsDateRangePreset` resolves the three in its half-open form. `next_30_days` stops before `{31_days_from_now}`. `next_month` and `next_quarter` stop before the month and the quarter after next, which no macro token names: the resolver reads `next_month_start` (or `next_quarter_start`) again from the start of the day it named, so the end is still a day the one token resolver computed. No macro token is added. + +In the console, the three presets have labels in all ten locale packs, and the client date-macro resolver now answers `{next_month_end}` and `{next_quarter_end}`, two spec tokens it did not resolve before. `@objectstack/lint` changes documentation only. diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index c2ed54ee728..f5ce1ff31af 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -107,7 +107,7 @@ const result = AnalyticsEndpoint.parse(data); | :--- | :--- | :--- | :--- | | **dimension** | `string` | ✅ | | | **granularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | | -| **dateRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| [string, string]` | optional | Time window for this dimension: a date-range PRESET name from the closed vocabulary in `data/date-range-presets.ts` (today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days — e.g. `'last_7_days'`), or an explicit `[start, end]` array of ISO dates / `{date-macro}` tokens (e.g. `["2023-01-01", "2023-01-31"]`). Any other string is refused at the schema with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`. | +| **dateRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| [string, string]` | optional | Time window for this dimension: a date-range PRESET name from the closed vocabulary in `data/date-range-presets.ts` (today, yesterday, this_week, last_week, this_month, last_month, next_month, this_quarter, last_quarter, next_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days, next_30_days — e.g. `'last_7_days'`), or an explicit `[start, end]` array of ISO dates / `{date-macro}` tokens (e.g. `["2023-01-01", "2023-01-31"]`). Any other string is refused at the schema with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`. | --- @@ -236,7 +236,7 @@ const result = AnalyticsEndpoint.parse(data); | :--- | :--- | :--- | :--- | | **dimension** | `string` | ✅ | | | **granularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | | -| **dateRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| [string, string]` | optional | Time window for this dimension: a date-range PRESET name from the closed vocabulary in `data/date-range-presets.ts` (today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days — e.g. `'last_7_days'`), or an explicit `[start, end]` array of ISO dates / `{date-macro}` tokens (e.g. `["2023-01-01", "2023-01-31"]`). Any other string is refused at the schema with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`. | +| **dateRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| [string, string]` | optional | Time window for this dimension: a date-range PRESET name from the closed vocabulary in `data/date-range-presets.ts` (today, yesterday, this_week, last_week, this_month, last_month, next_month, this_quarter, last_quarter, next_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days, next_30_days — e.g. `'last_7_days'`), or an explicit `[start, end]` array of ISO dates / `{date-macro}` tokens (e.g. `["2023-01-01", "2023-01-31"]`). Any other string is refused at the schema with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`. | ### Nested Shape: `DatasetSelection.compareTo` diff --git a/content/docs/references/data/analytics.mdx b/content/docs/references/data/analytics.mdx index 58ac7267a6c..0c92377f01f 100644 --- a/content/docs/references/data/analytics.mdx +++ b/content/docs/references/data/analytics.mdx @@ -52,7 +52,7 @@ This schema accepts one of the following structures: #### Option 1 -Allowed Values: `today`, `yesterday`, `this_week`, `last_week`, `this_month`, `last_month`, `this_quarter`, `last_quarter`, `this_year`, `last_year`, `last_7_days`, `last_30_days`, `last_90_days` +Allowed Values: `today`, `yesterday`, `this_week`, `last_week`, `this_month`, `last_month`, `next_month`, `this_quarter`, `last_quarter`, `next_quarter`, `this_year`, `last_year`, `last_7_days`, `last_30_days`, `last_90_days`, `next_30_days` --- @@ -75,13 +75,16 @@ Type: `[string, string]` * `last_week` * `this_month` * `last_month` +* `next_month` * `this_quarter` * `last_quarter` +* `next_quarter` * `this_year` * `last_year` * `last_7_days` * `last_30_days` * `last_90_days` +* `next_30_days` --- @@ -108,7 +111,7 @@ Type: `[string, string]` | :--- | :--- | :--- | :--- | | **dimension** | `string` | ✅ | | | **granularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | | -| **dateRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| [string, string]` | optional | Time window for this dimension: a date-range PRESET name from the closed vocabulary in `data/date-range-presets.ts` (today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days — e.g. `'last_7_days'`), or an explicit `[start, end]` array of ISO dates / `{date-macro}` tokens (e.g. `["2023-01-01", "2023-01-31"]`). Any other string is refused at the schema with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`. | +| **dateRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| [string, string]` | optional | Time window for this dimension: a date-range PRESET name from the closed vocabulary in `data/date-range-presets.ts` (today, yesterday, this_week, last_week, this_month, last_month, next_month, this_quarter, last_quarter, next_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days, next_30_days — e.g. `'last_7_days'`), or an explicit `[start, end]` array of ISO dates / `{date-macro}` tokens (e.g. `["2023-01-01", "2023-01-31"]`). Any other string is refused at the schema with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`. | --- diff --git a/content/docs/ui/dashboards.mdx b/content/docs/ui/dashboards.mdx index d61b7befc68..5982aa06e43 100644 --- a/content/docs/ui/dashboards.mdx +++ b/content/docs/ui/dashboards.mdx @@ -380,16 +380,19 @@ dateRange: { | `last_week` | Previous week | | `this_month` | Current month | | `last_month` | Previous month | +| `next_month` | Following month | | `this_quarter` | Current quarter | | `last_quarter` | Previous quarter | +| `next_quarter` | Following quarter | | `this_year` | Current year | | `last_year` | Previous year | | `last_7_days` | Rolling 7 days | | `last_30_days` | Rolling 30 days | | `last_90_days` | Rolling 90 days | +| `next_30_days` | Today and the 30 days after it | | `custom` | User-defined range | -The thirteen named ranges are published as `DATE_RANGE_PRESETS` in +The named ranges are published as `DATE_RANGE_PRESETS` in `@objectstack/spec/ui` — the vocabulary's single source of truth. `custom` is not one of them: it selects no window, it opens the picker. diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 6b8380c2ac8..82dbcef7c1b 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -1539,13 +1539,16 @@ "last_week", "this_month", "last_month", + "next_month", "this_quarter", "last_quarter", + "next_quarter", "this_year", "last_year", "last_7_days", "last_30_days", - "last_90_days" + "last_90_days", + "next_30_days" ], "enumSource": { "file": "packages/spec/src/data/date-range-presets.ts", From 7138ccab2cd74a8803d1947e1c7faaa3c6dff62b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:31:06 +0000 Subject: [PATCH 4/8] test(spec): move the dashboard preset count pin to sixteen Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzj9os3R42oiDRwN6HZ --- packages/spec/src/ui/dashboard.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index d4cc30c5385..629d9cb81bf 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -425,8 +425,9 @@ describe('date-range preset vocabulary — one source, in the spec', () => { it('is a closed vocabulary — counts pinned so a silent add/drop is loud', () => { // ADR-0122 receipt convention: assert the count, not just membership, so a // name appearing or vanishing cannot ride in under a passing test. - expect(DATE_RANGE_PRESETS).toHaveLength(13); - expect(DATE_RANGE_DEFAULT_RANGES).toHaveLength(14); + // 13 shipped, plus next_month, next_quarter and next_30_days. + expect(DATE_RANGE_PRESETS).toHaveLength(16); + expect(DATE_RANGE_DEFAULT_RANGES).toHaveLength(17); // `custom` names no window — it is a `defaultRange` sentinel only. expect(DATE_RANGE_PRESETS).not.toContain('custom'); From a2d35776db56fc7c299a459c85cebbf1714f2ce3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:51:55 +0000 Subject: [PATCH 5/8] fix(verify): write fixture rows on a forward preset's future edges The dashboard-window proof capped its fixture days at today, so the three forward presets selected no fixture rows and every proof under them failed. A window whose last day is after today now writes its own edges; the days written for the other presets are unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzj9os3R42oiDRwN6HZ --- .changeset/30076-forward-date-range-presets.md | 3 ++- packages/verify/src/dashboard-window.ts | 13 ++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.changeset/30076-forward-date-range-presets.md b/.changeset/30076-forward-date-range-presets.md index 51156e0901e..d08bf000ead 100644 --- a/.changeset/30076-forward-date-range-presets.md +++ b/.changeset/30076-forward-date-range-presets.md @@ -2,6 +2,7 @@ '@objectstack/spec': minor '@objectstack/core': minor '@objectstack/lint': patch +'@objectstack/verify': patch '@object-ui/core': minor '@object-ui/components': minor '@object-ui/i18n': minor @@ -18,4 +19,4 @@ The relative dates a dashboard date filter and the filter builder offer looked o `@objectstack/core`'s `resolveAnalyticsDateRangePreset` resolves the three in its half-open form. `next_30_days` stops before `{31_days_from_now}`. `next_month` and `next_quarter` stop before the month and the quarter after next, which no macro token names: the resolver reads `next_month_start` (or `next_quarter_start`) again from the start of the day it named, so the end is still a day the one token resolver computed. No macro token is added. -In the console, the three presets have labels in all ten locale packs, and the client date-macro resolver now answers `{next_month_end}` and `{next_quarter_end}`, two spec tokens it did not resolve before. `@objectstack/lint` changes documentation only. +In the console, the three presets have labels in all ten locale packs, and the client date-macro resolver now answers `{next_month_end}` and `{next_quarter_end}`, two spec tokens it did not resolve before. `@objectstack/verify`'s dashboard-window proof also writes fixture rows on a forward preset's future edges, so it judges the three new windows; the days it writes for the other presets are unchanged. `@objectstack/lint` changes documentation only. diff --git a/packages/verify/src/dashboard-window.ts b/packages/verify/src/dashboard-window.ts index 3fa6388f7ef..31b4006f1cb 100644 --- a/packages/verify/src/dashboard-window.ts +++ b/packages/verify/src/dashboard-window.ts @@ -43,7 +43,7 @@ // // Per widget and measure, fixture rows are written through the seed door // (system, hooks and validations live) on known days that straddle every -// preset's past edges, each row stamped at UTC noon (a `date` field takes the +// preset's edges (a forward preset's edges are future days), each row stamped at UTC noon (a `date` field takes the // day itself), with every column the widget's own filter, the dataset's filter // and the measure's filter read held to a value those filters select, and // owned by the principal. Each row is read back: a row whose stamp or held @@ -604,14 +604,21 @@ function shiftDay(day: string, n: number): string { /** This family's letter on every string value it varies (`derive.ts`'s `vary`). */ const FIXTURE_TAG = 'w'; -/** The days the fixtures are written on: each preset's first and last day and the day either side, never after today. */ +/** + * The days the fixtures are written on: each preset's first and last day and + * the day either side. A window that ends by today writes nothing after today; + * a FORWARD window (`next_month`, `next_quarter`, `next_30_days`), whose last + * day is after today, writes its own future edges too, since no past day can + * fall inside it. + */ function fixtureDays(presets: DashboardWindowPreset[], today: string): string[] { const days = new Set([today]); for (const p of presets) { const start = p.resolved.$gte as string; const end = p.resolved.$lte as string | undefined; + const forward = end !== undefined && end > today; for (const d of [start, shiftDay(start, -1), ...(end ? [end, shiftDay(end, 1)] : [])]) { - if (d <= today) days.add(d); + if (forward || d <= today) days.add(d); } } return [...days].sort().reverse(); From f7e8b2034a08adcb10e71b6f7f1d08873dd91ac4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:20:16 +0000 Subject: [PATCH 6/8] docs(qa): widen the date-range preset matrix to sixteen variants Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzj9os3R42oiDRwN6HZ --- docs/qa/platform-checklist/areas/api-backend.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 82dbcef7c1b..5767870704f 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -1477,7 +1477,7 @@ "title": "Every date-range preset resolves to its declared macro window, and a bare preset name used as a raw comparand is refused with the prescription", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "api", "personas": [ @@ -1489,7 +1489,7 @@ "an object with a date/datetime field carrying rows spread across the preset windows (today, this week, last quarter, last 90 days) so each preset has a distinguishable answer set" ], "knownGaps": [ - "Stock seeds are unlikely to place rows in every one of the 13 windows. Presets whose window contains no seeded row prove only that the window RESOLVED, not that it selected correctly — the run must record which presets were answer-discriminating and which were merely well-formed. Seeding rows at known offsets during the run is the honest way to close this; record it if done.", + "Stock seeds are unlikely to place rows in every one of the 16 windows, and the three forward ones (next_month, next_quarter, next_30_days) select only future-dated rows. Presets whose window contains no seeded row prove only that the window RESOLVED, not that it selected correctly — the run must record which presets were answer-discriminating and which were merely well-formed. Seeding rows at known offsets during the run is the honest way to close this; record it if done.", "Presets are relative to run time, so a run near a period boundary (month/quarter/year rollover, or a run spanning midnight) can legitimately shift an answer set. Capture the run's clock alongside the results." ] }, @@ -1553,7 +1553,7 @@ "enumSource": { "file": "packages/spec/src/data/date-range-presets.ts", "export": "DATE_RANGE_PRESETS", - "expect": 13 + "expect": 16 }, "traps": [ "clock-skew", @@ -1577,6 +1577,12 @@ "date": "2026-10-02", "change": "split clause 3 into its two designed arms. The clause expected the preset prescription for step 5's own example, an EQUALITY (where: { created: 'today' }); the platform deliberately raises that message only in ordering positions (18.filter-preset-ordering-comparand-refused.ts) and refuses an equality on a date field at the temporal door (400 INVALID_FILTER). Measured on 17.6.0 by the release-verification run: equality → INVALID_FILTER with the temporal message, $gte → VALIDATION_FAILED with bareDateRangePresetComparandMessage, neither ever 200. Step 5 now drives both arms", "ref": "#21330" + }, + { + "revision": 3, + "date": "2026-11-04", + "change": "variants widened from 13 to 16: DATE_RANGE_PRESETS gained the forward presets next_month, next_quarter and next_30_days (ruling A on the card). Their windows are [{next_month_start}, {next_month_end}], [{next_quarter_start}, {next_quarter_end}] and [{today}, {30_days_from_now}]; the bare-comparand refusal prescribes them like every other preset. A forward window needs future-dated rows to discriminate, which the knownGaps line now says", + "ref": "#30076" } ] }, From 186fb6019ead8de9ede5fae9d73611f4e8d32bac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:38:17 +0000 Subject: [PATCH 7/8] fix(core): re-anchor a forward preset's end at noon of the anchor day A zone that springs forward at 00:00 on a period's first day (America/ Asuncion, 2023-10-01) has no midnight, and the day-start instant read as the day before, so next_month and next_quarter came out empty there. Also drops stale preset counts from the verify docs and lists the forward presets in the plugin-dashboard README. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzj9os3R42oiDRwN6HZ --- content/docs/deployment/cli.mdx | 4 ++-- .../src/utils/analytics-date-range.test.ts | 7 +++++++ .../core/src/utils/analytics-date-range.ts | 18 +++++++++++++++--- packages/verify/README.md | 7 ++++--- ...shboard-filters.presetWindows-29116.test.ts | 3 +++ ui/packages/plugin-dashboard/README.md | 2 +- 6 files changed, 32 insertions(+), 9 deletions(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index c0fcece3bc9..b0acd520d19 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -2592,12 +2592,12 @@ os verify --dashboard-windows # Also prove each dashboard date measure of a bound widget that is a count, a sum, or a derived measure over those, fixture rows are written through the seed door on every preset's first and last day and the day either side (never after - today), stamped at UTC noon, owned by the caller and holding every column + today, except a forward preset's own edges, which are future days), stamped at UTC noon, owned by the caller and holding every column the widget's own filter, the dataset's filter and the measure's filter compare (the smallest record first, every synthesized field if the seed door refuses it), and read back. The widget is then asked through `POST /analytics/dataset/query` as the signed-in admin with no window and - under each of the 13 presets, before and after the fixtures. A preset is + under each preset, before and after the fixtures. A preset is handed as the spec's `{date-macro}` window for it (`{ $gte: '{7_days_ago}', $lte: '{today}' }` for `last_7_days`, `{ $gte: '{month_start}', $lte: '{month_end}' }` for `this_month`), ANDed with the widget's filter, and the server resolves the diff --git a/packages/core/src/utils/analytics-date-range.test.ts b/packages/core/src/utils/analytics-date-range.test.ts index 3ec44f3498f..8e9d46ffa44 100644 --- a/packages/core/src/utils/analytics-date-range.test.ts +++ b/packages/core/src/utils/analytics-date-range.test.ts @@ -239,6 +239,13 @@ describe('#30076 — the forward presets: next_month, next_quarter, next_30_days { preset: 'next_30_days', now: '2026-10-20T12:00:00.000Z', tz: 'America/New_York', start: '2026-10-20T04:00:00.000Z', end: '2026-11-20T05:00:00.000Z', why: 'New York, window crosses DST end' }, // A non-whole-hour zone on both sides of its DST end (Apr 5 2026). { preset: 'next_month', now: '2026-03-15T00:00:00.000Z', tz: 'Pacific/Chatham', start: '2026-03-31T10:15:00.000Z', end: '2026-04-30T11:15:00.000Z', why: 'Chatham +13:45 then +12:45' }, + // A zone that springs forward AT 00:00 on the period's first day: + // 2023-10-01 has no midnight in Asuncion, so its first instant is + // 01:00 -03 (04:00Z) and the zone helper settles on 03:00Z, which + // reads as Sep 30. Re-anchoring there gave an EMPTY window + // (end === start); the noon anchor keeps the resolver inside Oct 1. + { preset: 'next_month', now: '2023-09-15T12:00:00.000Z', tz: 'America/Asuncion', start: '2023-10-01T03:00:00.000Z', end: '2023-11-01T03:00:00.000Z', why: 'Asuncion springs forward at 00:00 on Oct 1' }, + { preset: 'next_quarter', now: '2023-08-15T12:00:00.000Z', tz: 'America/Asuncion', start: '2023-10-01T03:00:00.000Z', end: '2024-01-01T03:00:00.000Z', why: 'Asuncion, quarter opens on the gap day' }, ]; it.each(ROWS)('$preset @ $now ($why)', ({ preset, now, tz, start, end }) => { diff --git a/packages/core/src/utils/analytics-date-range.ts b/packages/core/src/utils/analytics-date-range.ts index 82a25636fd2..3bf3b721911 100644 --- a/packages/core/src/utils/analytics-date-range.ts +++ b/packages/core/src/utils/analytics-date-range.ts @@ -141,7 +141,8 @@ export interface ResolvedAnalyticsDateRange { * period grammar stops at `next_`. The re-anchored form names that day without * calendar arithmetic here and without widening the token vocabulary: resolve * {@link from} as usual, then resolve {@link token} AGAIN with the reference - * day moved to the start of that day in the same zone. `{ token: + * day moved into that day in the same zone (to its noon; see + * {@link boundDayStart}). `{ token: * 'next_month_start', from: 'next_month_start' }` is "next month's start, as * seen from next month's start", which is the month after next. Every day in * the answer is still one the one resolver computed, so its month and quarter @@ -196,14 +197,25 @@ function tokenDayStartMs(token: string, now: Date, timezone?: string): number { return zonedDateStartToUtcMs(String(day), timezone); } +/** Twelve hours: from a day's first instant, always inside that same local day (a day is 23 to 25 hours). */ +const HALF_DAY_MS = 12 * 60 * 60 * 1000; + /** * One bound's instant. A re-anchored bound resolves its `from` token first and - * then its `token` with the reference instant moved to the start of that day + * then its `token` with the reference instant moved to about noon of that day * in `timezone`, so the resolver reads the anchor day as "today". + * + * ⛔ Not the day's first instant. Where a zone springs forward at 00:00 on a + * period's first day (America/Asuncion on 2023-10-01), that day has no + * midnight, and the instant the zone helper settles on reads as the day + * BEFORE: "next month seen from there" is the same month again, and the + * window comes out empty. Twelve hours later is inside the anchor day in any + * zone. This is only where the resolver stands, never a bound: both bounds are + * still days the resolver named, rendered at their own day start. */ function boundDayStart(bound: PresetWindowBound, now: Date, timezone?: string): string { if (typeof bound === 'string') return tokenDayStart(bound, now, timezone); - const anchor = new Date(tokenDayStartMs(bound.from, now, timezone)); + const anchor = new Date(tokenDayStartMs(bound.from, now, timezone) + HALF_DAY_MS); return tokenDayStart(bound.token, anchor, timezone); } diff --git a/packages/verify/README.md b/packages/verify/README.md index e275965ac78..ec2c201a76e 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -41,7 +41,7 @@ derived from your own metadata: per bound widget and measure (a count, a sum, or a derived measure over those), fixture rows are written on known days around every preset's edges, and the widget is asked through the dataset door - with no window and under each of the 13 presets, before and after the + with no window and under each preset, before and after the fixtures. Each difference must equal the fixture rows the window selects; a window that answers nothing is `blank`, and one that never narrows is inert. @@ -165,7 +165,8 @@ server resolves the tokens, and the ground truth resolves the same window with `@objectstack/core`'s `resolveFilterTokens` on the caller's reference timezone (UTC unless the `ExecutionContext` carries one). Fixture rows are written through the seed door on each preset's first and last day and the day either -side (never after today), each stamped at UTC noon (a `date` field takes the +side (never after today, except a forward preset's own edges, which are +future days), each stamped at UTC noon (a `date` field takes the day), owned by the caller, with every column the widget's filter, the dataset's filter and the measure's filter compare held to a value they select, and read back before anything is judged. A row is the smallest record the CRUD @@ -511,7 +512,7 @@ compute one, so it is not read as "no grant anywhere". `summary.hardFailures` is **Dashboard date windows** (`runDashboardWindowProofs`), per widget a dashboard's `dateRange` binds. Probes per measure that is a count, a sum or derived over those: the widget with no -window, then each of the 13 presets, each counted before and after the +window, then each preset, each counted before and after the fixture rows: | status | meaning | 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 6336c6f0fb9..359267ae27a 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,6 +66,9 @@ 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. const RETIRED_PRESETS = [...DATE_RANGE_PRESETS].filter((p) => !FORWARD_PRESETS.includes(p)); /** diff --git a/ui/packages/plugin-dashboard/README.md b/ui/packages/plugin-dashboard/README.md index b6ec9b24f0a..7703e2c986c 100644 --- a/ui/packages/plugin-dashboard/README.md +++ b/ui/packages/plugin-dashboard/README.md @@ -412,7 +412,7 @@ into each bound widget's inline query (`AND`-combined with the widget's own "type": "dashboard", "dateRange": { "field": "created_at", // default binding target - "defaultRange": "last_30_days", // today | this_week | … | last_90_days | custom + "defaultRange": "last_30_days", // today | this_week | … | last_90_days | next_month | next_quarter | next_30_days | custom "allowCustomRange": true // offer a custom from/to calendar }, "globalFilters": [ From 5af3917a89fc44c756aef0dfb386824b19e323f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 16:26:55 +0000 Subject: [PATCH 8/8] test(cli): read the dashboard-window probe count from DATE_RANGE_PRESETS The os verify --dashboard-windows pin counted 13 probes and 13 held; with the three forward presets the run reads 16. Both now follow the vocabulary's length. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzj9os3R42oiDRwN6HZ --- packages/cli/test/verify-dashboard-windows.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/verify-dashboard-windows.test.ts b/packages/cli/test/verify-dashboard-windows.test.ts index 131d8b91e23..6abda51fbaa 100644 --- a/packages/cli/test/verify-dashboard-windows.test.ts +++ b/packages/cli/test/verify-dashboard-windows.test.ts @@ -29,6 +29,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { CLI, childEnv } from './helpers/serve-process.js'; import { defineStackSource, defineStackSourceFromLiteral, linkSpec } from './helpers/define-stack-fixture.js'; +import { DATE_RANGE_PRESETS } from '@objectstack/spec/data'; const MANIFEST = { id: 'com.example.verify-dashboard-windows', @@ -161,7 +162,14 @@ describe('os verify --dashboard-windows', () => { const report = docOf(proven).dashboardWindows!; expect(report.door).toBe('rest'); expect(report.results).toEqual([expect.objectContaining({ dashboard: 'vdw_board', widget: 'cases', status: 'proven' })]); - expect(report.summary).toMatchObject({ widgets: { proven: 1, contradicted: 0 }, probes: 13, held: 13, inert: 0, hardFailures: 0 }); + // One probe per preset of the picker, read from the vocabulary so a new preset moves it too. + expect(report.summary).toMatchObject({ + widgets: { proven: 1, contradicted: 0 }, + probes: DATE_RANGE_PRESETS.length, + held: DATE_RANGE_PRESETS.length, + inert: 0, + hardFailures: 0, + }); expect(docOf(proven).hardFailures).toBe(0); });