Skip to content

Commit f752ee3

Browse files
authored
feat(analytics): order the time axis by default, give reports a sort declaration (#3916) (#3921)
A matrix report with a date dimension across rendered its columns in arbitrary order. Declaring `dateGranularity` made the bucket keys sortable without making anything sort them, and nothing in the chain supplied an order: `resolveOrdering` returned undefined unless the selection carried one, the ObjectQL aggregate path has no ordering grammar so buckets came back in Map-insertion order, and the pivot builds its column headers in row-arrival order. The report author could not ask either — `DatasetSelection.order` existed on the wire but `ReportSchema` had no ordering field at all. Server default: when a selection states no `order` (and no `limit`, whose own fallback already ordered by every dimension), each selected dimension the cube types as `time` defaults to ascending, in selection order. Bucket keys are minted sort-stable precisely so this works. Lands on both strategy paths — a real ORDER BY where native SQL serves the query, the executor's post-pass where a bucketed query goes to the ObjectQL path. Deliberately narrow: only time dimensions get a default, so grids with nothing wrong with them are not reordered. Author surface: `ReportSchema.order` / `blocks[].order` is a list of `{ by, direction }` keys, most significant first. `by` must name a `rows`/`columns` dimension or a `values` measure the report selects; anything else, or a duplicate, fails at authoring time. A `joined` report orders per block. `reportSelectionOrder()` lowers the list into `DatasetSelection.order`, returning undefined for an empty list so the runtime defaults still apply. Ships as `planned` + authorWarn in the liveness ledger: the framework half is complete and live, but objectui's DatasetReportRenderer does not yet carry `report.order` into the selection it posts. The time-axis default needs no renderer change and is live now. Also regenerates the platform-objects metadata-form i18n bundles the new `order` form field drifted, with the zh-CN / ja-JP / es-ES entries hand-translated — platform-objects sits at 0 in the i18n coverage baseline, so untranslated filler would have tripped the strict gate.
1 parent 4cf7c61 commit f752ee3

18 files changed

Lines changed: 582 additions & 6 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/service-analytics": patch
4+
---
5+
6+
feat(analytics): order the time axis by default, and give reports a sort declaration (#3916)
7+
8+
A matrix report with a date dimension across rendered its columns in arbitrary
9+
order — `2026-07-01, 2026-07-05, …, 2026-07-02`. Declaring `dateGranularity` on
10+
the dataset dimension made the bucket keys *sortable* (`2026-07`, `2026-Q3`)
11+
without making anything *sort* them, and the report author had no way to ask:
12+
`DatasetSelection.order` existed on the wire, but `ReportSchema` had no ordering
13+
field at all (dashboard widgets had their own `options.sortBy` channel; reports
14+
did not). Nothing in the chain supplied an order either — `resolveOrdering`
15+
returned `undefined` unless the selection carried one explicitly, the ObjectQL
16+
aggregate path has no ordering grammar so its buckets came back in Map-insertion
17+
order, and the pivot builds its column headers in row-arrival order.
18+
19+
- **A selected time dimension is now chronological by default.** When a
20+
selection states no `order` (and no `limit`, whose own fallback already
21+
ordered by every dimension), each selected dimension the cube types as `time`
22+
defaults to ASCENDING, in selection order. Bucket keys are minted sort-stable
23+
precisely so this works — `2026-07` sorts after `2026-06`, `2026-Q3` after
24+
`2026-Q1`. This lands on both strategy paths: a real `ORDER BY` where native
25+
SQL serves the query, and the executor's post-pass where a date-bucketed query
26+
is handed to the ObjectQL path. Null / empty buckets stay last, as everywhere
27+
else. Deliberately narrow: only time dimensions get a default, so grids with
28+
nothing wrong with them are not reordered.
29+
- **Reports can declare an ordering.** `ReportSchema.order` (and
30+
`blocks[].order` for a `joined` report) is a list of `{ by, direction }` sort
31+
keys, most significant first — an array, not a `Record`, because key order is
32+
the contract and JSON object key order should not have to be. `by` must name a
33+
dimension the report groups by (`rows` / `columns`) or a measure it displays
34+
(`values`); anything else fails at authoring time rather than becoming an
35+
ordering that silently does nothing. Duplicate keys are rejected. A `joined`
36+
report orders per block — declaring `order` on the container is an error.
37+
`reportSelectionOrder()` lowers the list into the `DatasetSelection.order` a
38+
renderer posts, and returns `undefined` for an empty list so the runtime's own
39+
defaults still apply.
40+
41+
An explicit `order` still wins outright — the chronological default is a
42+
default, not a policy, so "newest month first" is one declaration away.
43+
44+
`report.order` ships as `planned` + `authorWarn` in the liveness ledger: the
45+
framework half is complete and live (schema, lowering helper, executor), but
46+
objectui's `DatasetReportRenderer` does not yet carry `report.order` into the
47+
selection it posts. The default time-axis ordering needs no renderer change and
48+
is live now.

content/docs/data-modeling/analytics.mdx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,43 @@ export const SalesByStageReport = {
126126
`rows` are the pivot's down-axis dimensions; `values` are measure names. A matrix
127127
report adds across-axis dimensions; `runtimeFilter` is the render-time scope.
128128

129+
## Ordering the result
130+
131+
A selected **date dimension is chronological by default** — ascending, on the
132+
down axis and the across axis alike — so a month-bucketed matrix reads
133+
left-to-right in time without declaring anything. Every other dimension keeps
134+
the order the grouping produced until you ask for one.
135+
136+
To ask, declare `order`: a list of sort keys, most significant first. Each key
137+
names a dimension the report groups by (`rows` / `columns`) or a measure it
138+
displays (`values`); anything else is an authoring error rather than an ordering
139+
that silently does nothing.
140+
141+
{/* os:check */}
142+
```ts
143+
export const HoursByOwnerReport = {
144+
name: 'hours_by_owner',
145+
label: 'Hours by Owner',
146+
type: 'matrix',
147+
dataset: 'tasks',
148+
rows: ['owner'], // down axis
149+
columns: ['closed_month'], // across axis — a month-bucketed date dimension
150+
values: ['est_hours'],
151+
order: [
152+
{ by: 'closed_month', direction: 'desc' }, // newest month first (overrides the default)
153+
{ by: 'est_hours', direction: 'desc' }, // then the biggest owner first
154+
],
155+
};
156+
```
157+
158+
Ordering is applied **server-side over the whole grid** — after measure-scoped
159+
filters merge and derived measures evaluate — so a derived measure is a valid
160+
sort key even though no single SQL statement computes it. Null / empty buckets
161+
sort last in both directions.
162+
163+
A `joined` report orders per block: put `order` on each `blocks[]` entry, not on
164+
the container.
165+
129166
Placeholders inside a `filter` / `runtimeFilter``{date-macro}`s and the
130167
session tokens `{current_user_id}` / `{current_org_id}` — are resolved on **both**
131168
sides: by the renderer before it queries, and by the analytics dataset executor

content/docs/references/ui/report.mdx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ Report Type Enum
1414
## TypeScript Usage
1515

1616
```typescript
17-
import { JoinedReportBlock, Report, ReportChart, ReportType } from '@objectstack/spec/ui';
18-
import type { JoinedReportBlock, Report, ReportChart, ReportType } from '@objectstack/spec/ui';
17+
import { JoinedReportBlock, Report, ReportChart, ReportSort, ReportType } from '@objectstack/spec/ui';
18+
import type { JoinedReportBlock, Report, ReportChart, ReportSort, ReportType } from '@objectstack/spec/ui';
1919

2020
// Validate data
2121
const result = JoinedReportBlock.parse(data);
@@ -39,6 +39,7 @@ const result = JoinedReportBlock.parse(data);
3939
| **columns** | `string[]` | optional | Dimension names across (matrix, dataset-bound) |
4040
| **values** | `string[]` | optional | Measure names to show (dataset-bound) |
4141
| **runtimeFilter** | `any` | optional | Render-time scope filter (dataset-bound) |
42+
| **order** | `{ by: string; direction: Enum<'asc' \| 'desc'> }[]` | optional | Result ordering, most significant key first |
4243

4344

4445
---
@@ -58,6 +59,7 @@ const result = JoinedReportBlock.parse(data);
5859
| **columns** | `string[]` | optional | Dimension names across (matrix) |
5960
| **values** | `string[]` | optional | Measure names to show |
6061
| **runtimeFilter** | `any` | optional | Render-time scope filter |
62+
| **order** | `{ by: string; direction: Enum<'asc' \| 'desc'> }[]` | optional | Result ordering, most significant key first |
6163
| **drilldown** | `boolean` || Click-through to underlying records |
6264
| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>; title?: string; subtitle?: string; description?: string; … }` | optional | Embedded chart configuration |
6365
| **blocks** | `{ name: string; label?: string; description?: string; type: Enum<'tabular' \| 'summary' \| 'matrix'>; … }[]` | optional | Sub-reports for type=joined |
@@ -95,6 +97,18 @@ const result = JoinedReportBlock.parse(data);
9597
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
9698

9799

100+
---
101+
102+
## ReportSort
103+
104+
### Properties
105+
106+
| Property | Type | Required | Description |
107+
| :--- | :--- | :--- | :--- |
108+
| **by** | `string` || Dimension or measure name to order by (must be selected by this report) |
109+
| **direction** | `Enum<'asc' \| 'desc'>` || Sort direction (default ascending) |
110+
111+
98112
---
99113

100114
## ReportType

docs/audits/2026-06-reportschema-property-liveness.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818

1919
## 🔴 Obsolete sub-schemas (type-only re-exports, fully superseded by ADR-0021)
2020
- `ReportColumnSchema` (`field/label/aggregate/responsive`) — replaced by plain `string[]` `values`; per-column aggregate/label now live in the **dataset** definition.
21-
- `ReportGroupingSchema` (`field/sortOrder/dateGranularity`) — replaced by `string[]` `rows`; sort/granularity now in the **dataset**.
21+
- `ReportGroupingSchema` (`field/sortOrder/dateGranularity`) — replaced by `string[]` `rows`; granularity now in the **dataset** (`dimensions[].dateGranularity`).
22+
> **Correction — 2026-07-29 (#3916).** The retired `sortOrder` did **not** land on the dataset: no dataset-level sort field ever existed, and `DatasetSelection.order` was unreachable for report authors. A matrix report's date columns therefore rendered in row-arrival order. Ordering now lives on the **report** (`ReportSchema.order` / `blocks[].order`, lowered to `DatasetSelection.order` by `reportSelectionOrder`), and a selected time dimension defaults to ascending server-side.
2223
- `ReportChartSchema` (`xAxis/yAxis/groupBy`) — **naming drift**: the only chart code (`ReportViewer.tsx:329`) reads legacy `xAxisField/yAxisFields`, never the spec's `xAxis/yAxis` → spec chart field names are dead.
2324

2425
## Studio gap (PARTIAL)

packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,6 +1232,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
12321232
label: "Columns",
12331233
helpText: "Columns to display in the report"
12341234
},
1235+
order: {
1236+
label: "Order",
1237+
helpText: "Sort keys, most significant first (a rows/columns dimension or a values measure). Time dimensions are chronological by default."
1238+
},
12351239
drilldown: {
12361240
label: "Drilldown",
12371241
helpText: "Click an aggregated row/cell to open the underlying records"

packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,6 +1232,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
12321232
label: "Columnas",
12331233
helpText: "Columnas que mostrar en el informe"
12341234
},
1235+
order: {
1236+
label: "Orden",
1237+
helpText: "Claves de ordenación, la más significativa primero (una dimensión de rows/columns o una medida de values). Las dimensiones de tiempo son cronológicas de forma predeterminada."
1238+
},
12351239
drilldown: {
12361240
label: "Drilldown",
12371241
helpText: "Click an aggregated row/cell to open the underlying records"

packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,6 +1232,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
12321232
label: "列",
12331233
helpText: "レポートに表示する列"
12341234
},
1235+
order: {
1236+
label: "並び順",
1237+
helpText: "ソートキー(優先度の高い順)。rows/columns のディメンション、または values のメジャーを指定します。時間ディメンションは既定で時系列順です。"
1238+
},
12351239
drilldown: {
12361240
label: "Drilldown",
12371241
helpText: "Click an aggregated row/cell to open the underlying records"

packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,6 +1232,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
12321232
label: "列",
12331233
helpText: "报表中显示的列"
12341234
},
1235+
order: {
1236+
label: "排序",
1237+
helpText: "排序键,最重要的在前(rows/columns 维度或 values 度量)。时间维度默认按时间顺序排列。"
1238+
},
12351239
drilldown: {
12361240
label: "Drilldown",
12371241
helpText: "Click an aggregated row/cell to open the underlying records"
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #3916 — a selected time dimension comes back CHRONOLOGICAL by default.
5+
*
6+
* The reported symptom was a matrix report whose date COLUMNS rendered in
7+
* arbitrary order (`2026-07-01, 2026-07-05, …, 2026-07-02`). Nothing in the
8+
* stack ordered them: `resolveOrdering` returned `undefined` unless the
9+
* selection carried an explicit `order`, the ObjectQL aggregate path has no
10+
* ordering grammar (its buckets come back in Map-insertion order), and the
11+
* pivot builds its column headers in row-ARRIVAL order. Declaring
12+
* `dateGranularity` made the keys sortable without making anything sort them.
13+
*
14+
* These tests pin the default and its boundaries: it applies to time dimensions
15+
* only, it never overrides an explicit `order`, and it survives on both strategy
16+
* paths — as a real `ORDER BY` where native SQL serves the query, and as the
17+
* executor's post-pass where a bucketed query is handed to the ObjectQL path.
18+
*/
19+
20+
import { describe, it, expect } from 'vitest';
21+
import { DatasetSchema } from '@objectstack/spec/ui';
22+
import type { ExecutionContext } from '@objectstack/spec/kernel';
23+
import { AnalyticsService } from '../analytics-service.js';
24+
import { resolveOrdering } from '../dataset-executor.js';
25+
26+
const CTX = { tenantId: 'org_A' } as ExecutionContext;
27+
28+
/** The HotCRM shape: a date dimension across, a string dimension down. */
29+
const tasks = DatasetSchema.parse({
30+
name: 'task_metrics',
31+
label: 'Tasks',
32+
object: 'crm_task',
33+
include: [],
34+
dimensions: [
35+
{ name: 'due_date', field: 'due_date', type: 'date' },
36+
{ name: 'closed_month', field: 'closed_at', type: 'date', dateGranularity: 'month' },
37+
{ name: 'owner', field: 'owner', type: 'string' },
38+
],
39+
measures: [
40+
{ name: 'task_count', aggregate: 'count' },
41+
{ name: 'est_hours', aggregate: 'sum', field: 'est_hours' },
42+
],
43+
});
44+
45+
/**
46+
* ObjectQL-aggregate service — the path every date-BUCKETED query is handed to.
47+
* Stub rows are keyed by the underlying FIELD name (`closed_at`), which is what
48+
* the engine returns; the strategy remaps them onto the dimension names.
49+
*/
50+
function aggService(rows: Record<string, unknown>[]) {
51+
return new AnalyticsService({
52+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
53+
executeAggregate: async () => rows,
54+
});
55+
}
56+
57+
/** Native-SQL service; captures every statement it is asked to run. */
58+
function sqlService(rows: Record<string, unknown>[], captured: string[] = []) {
59+
const svc = new AnalyticsService({
60+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
61+
executeRawSql: async (_o, sql) => { captured.push(sql); return rows; },
62+
});
63+
return { svc, captured };
64+
}
65+
66+
describe('#3916 — resolveOrdering defaults the time axis to ascending', () => {
67+
const selection = { measures: ['task_count'] };
68+
69+
it('orders a selected time dimension ascending when nothing else asked', () => {
70+
expect(resolveOrdering(selection, ['closed_month'], ['closed_month'])).toEqual({ closed_month: 'asc' });
71+
});
72+
73+
it('leaves a selection with no time dimension unordered, exactly as before', () => {
74+
expect(resolveOrdering(selection, ['owner'], [])).toBeUndefined();
75+
});
76+
77+
it('orders ONLY the time dimensions — a non-time dimension keeps the grouping order', () => {
78+
// Deliberately narrow: sorting every dimension by default would reorder
79+
// grids that have nothing wrong with them. The row axis stays as-is.
80+
expect(resolveOrdering(selection, ['owner', 'closed_month'], ['closed_month']))
81+
.toEqual({ closed_month: 'asc' });
82+
});
83+
84+
it('keeps multiple time dimensions in selection order (first is the primary sort)', () => {
85+
expect(resolveOrdering(selection, ['due_date', 'owner', 'closed_month'], ['due_date', 'closed_month']))
86+
.toEqual({ due_date: 'asc', closed_month: 'asc' });
87+
});
88+
89+
it('never overrides an explicit order — the default is a default, not a policy', () => {
90+
expect(
91+
resolveOrdering({ ...selection, order: { closed_month: 'desc' } }, ['closed_month'], ['closed_month']),
92+
).toEqual({ closed_month: 'desc' });
93+
});
94+
95+
it('yields to the bare-limit fallback, which orders EVERY dimension for a reproducible window', () => {
96+
expect(resolveOrdering({ ...selection, limit: 10 }, ['owner', 'closed_month'], ['closed_month']))
97+
.toEqual({ owner: 'asc', closed_month: 'asc' });
98+
});
99+
100+
it('ignores a time dimension the selection does not actually group by', () => {
101+
expect(resolveOrdering(selection, ['owner'], ['closed_month'])).toBeUndefined();
102+
});
103+
});
104+
105+
describe('#3916 — matrix date columns arrive chronological end-to-end', () => {
106+
it('sorts month buckets the aggregate path emitted in insertion order', async () => {
107+
// Exactly the reported grid: the driver's bucket Map hands back July before
108+
// June before August, and nothing downstream fixed it.
109+
const svc = aggService([
110+
{ owner: 'ann', closed_at: '2026-07', task_count: 3 },
111+
{ owner: 'ann', closed_at: '2026-06', task_count: 5 },
112+
{ owner: 'bob', closed_at: '2026-08', task_count: 2 },
113+
{ owner: 'bob', closed_at: '2026-06', task_count: 1 },
114+
]);
115+
const result = await svc.queryDataset(
116+
tasks,
117+
{ dimensions: ['owner', 'closed_month'], measures: ['task_count'] },
118+
CTX,
119+
);
120+
// The pivot builds its column headers in row-ARRIVAL order, so the distinct
121+
// months must arrive ascending for the across-axis to read left-to-right.
122+
const months = [...new Set(result.rows.map((r) => r.closed_month))];
123+
expect(months).toEqual(['2026-06', '2026-07', '2026-08']);
124+
});
125+
126+
it('sorts quarter and year keys chronologically too (bucket keys are minted sort-stable)', async () => {
127+
const quarterly = DatasetSchema.parse({
128+
name: 'quarterly_tasks', label: 'Quarterly', object: 'crm_task', include: [],
129+
dimensions: [{ name: 'closed_quarter', field: 'closed_at', type: 'date', dateGranularity: 'quarter' }],
130+
measures: [{ name: 'task_count', aggregate: 'count' }],
131+
});
132+
const svc = aggService([
133+
{ closed_at: '2026-Q3', task_count: 1 },
134+
{ closed_at: '2025-Q4', task_count: 2 },
135+
{ closed_at: '2026-Q1', task_count: 3 },
136+
]);
137+
const result = await svc.queryDataset(
138+
quarterly,
139+
{ dimensions: ['closed_quarter'], measures: ['task_count'] },
140+
CTX,
141+
);
142+
expect(result.rows.map((r) => r.closed_quarter)).toEqual(['2025-Q4', '2026-Q1', '2026-Q3']);
143+
});
144+
145+
it('emits a real ORDER BY when native SQL serves the query', async () => {
146+
// An UNbucketed date dimension stays on the native path (native SQL declines
147+
// granularity), so the ordering becomes server-side SQL rather than a
148+
// post-pass — the "no ORDER BY in the aggregate SQL" half of the report.
149+
const { svc, captured } = sqlService([{ due_date: '2026-07-01', task_count: 1 }]);
150+
await svc.queryDataset(tasks, { dimensions: ['due_date'], measures: ['task_count'] }, CTX);
151+
expect(captured[0]).toContain('ORDER BY "due_date" ASC');
152+
});
153+
154+
it('adds no ORDER BY for a selection with no time dimension', async () => {
155+
const { svc, captured } = sqlService([{ owner: 'ann', task_count: 1 }]);
156+
await svc.queryDataset(tasks, { dimensions: ['owner'], measures: ['task_count'] }, CTX);
157+
expect(captured[0]).not.toContain('ORDER BY');
158+
});
159+
160+
it('an explicit desc order still wins end-to-end (newest month first)', async () => {
161+
const svc = aggService([
162+
{ closed_at: '2026-06', task_count: 5 },
163+
{ closed_at: '2026-08', task_count: 2 },
164+
{ closed_at: '2026-07', task_count: 3 },
165+
]);
166+
const result = await svc.queryDataset(
167+
tasks,
168+
{ dimensions: ['closed_month'], measures: ['task_count'], order: { closed_month: 'desc' } },
169+
CTX,
170+
);
171+
expect(result.rows.map((r) => r.closed_month)).toEqual(['2026-08', '2026-07', '2026-06']);
172+
});
173+
174+
it('keeps the empty (null) bucket last, as every other ordering does', async () => {
175+
// #3839 made the empty bucket a real `null`; `compareValues` sorts nulls
176+
// last in BOTH directions, so an undated row cannot lead the time axis.
177+
const svc = aggService([
178+
{ closed_at: null, task_count: 9 },
179+
{ closed_at: '2026-07', task_count: 3 },
180+
{ closed_at: '2026-06', task_count: 5 },
181+
]);
182+
const result = await svc.queryDataset(
183+
tasks,
184+
{ dimensions: ['closed_month'], measures: ['task_count'] },
185+
CTX,
186+
);
187+
expect(result.rows.map((r) => r.closed_month)).toEqual(['2026-06', '2026-07', null]);
188+
});
189+
});

0 commit comments

Comments
 (0)