Skip to content
Merged
22 changes: 22 additions & 0 deletions .changeset/30076-forward-date-range-presets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/spec': minor
'@objectstack/core': minor
'@objectstack/lint': patch
'@objectstack/verify': 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/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.
4 changes: 2 additions & 2 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/api/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |


---
Expand Down Expand Up @@ -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`

Expand Down
7 changes: 5 additions & 2 deletions content/docs/references/data/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`

---

Expand All @@ -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`


---
Expand All @@ -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`. |


---
Expand Down
5 changes: 4 additions & 1 deletion content/docs/ui/dashboards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
17 changes: 13 additions & 4 deletions docs/qa/platform-checklist/areas/api-backend.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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."
]
},
Expand Down Expand Up @@ -1539,18 +1539,21 @@
"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",
"export": "DATE_RANGE_PRESETS",
"expect": 13
"expect": 16
},
"traps": [
"clock-skew",
Expand All @@ -1574,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"
}
]
},
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/test/verify-dashboard-windows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export async function analyticsDateRangeConformanceFindings(
const say = (msg: string) => findings.push(`${face.name}: ${msg}`);
const seenWindows = new Set<string>();

// ── 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);
Expand Down
Loading
Loading