Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/report-ordering-and-time-axis-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/spec": patch
"@objectstack/service-analytics": patch
---

feat(analytics): order the time axis by default, and give reports a sort declaration (#3916)

A matrix report with a date dimension across rendered its columns in arbitrary
order — `2026-07-01, 2026-07-05, …, 2026-07-02`. Declaring `dateGranularity` on
the dataset dimension made the bucket keys *sortable* (`2026-07`, `2026-Q3`)
without making anything *sort* them, and the report author had no way to ask:
`DatasetSelection.order` existed on the wire, but `ReportSchema` had no ordering
field at all (dashboard widgets had their own `options.sortBy` channel; reports
did not). Nothing in the chain supplied an order either — `resolveOrdering`
returned `undefined` unless the selection carried one explicitly, the ObjectQL
aggregate path has no ordering grammar so its buckets came back in Map-insertion
order, and the pivot builds its column headers in row-arrival order.

- **A selected time dimension is now chronological by 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 — `2026-07` sorts after `2026-06`, `2026-Q3` after
`2026-Q1`. This lands on both strategy paths: a real `ORDER BY` where native
SQL serves the query, and the executor's post-pass where a date-bucketed query
is handed to the ObjectQL path. Null / empty buckets stay last, as everywhere
else. Deliberately narrow: only time dimensions get a default, so grids with
nothing wrong with them are not reordered.
- **Reports can declare an ordering.** `ReportSchema.order` (and
`blocks[].order` for a `joined` report) is a list of `{ by, direction }` sort
keys, most significant first — an array, not a `Record`, because key order is
the contract and JSON object key order should not have to be. `by` must name a
dimension the report groups by (`rows` / `columns`) or a measure it displays
(`values`); anything else fails at authoring time rather than becoming an
ordering that silently does nothing. Duplicate keys are rejected. A `joined`
report orders per block — declaring `order` on the container is an error.
`reportSelectionOrder()` lowers the list into the `DatasetSelection.order` a
renderer posts, and returns `undefined` for an empty list so the runtime's own
defaults still apply.

An explicit `order` still wins outright — the chronological default is a
default, not a policy, so "newest month first" is one declaration away.

`report.order` ships as `planned` + `authorWarn` in the liveness ledger: the
framework half is complete and live (schema, lowering helper, executor), but
objectui's `DatasetReportRenderer` does not yet carry `report.order` into the
selection it posts. The default time-axis ordering needs no renderer change and
is live now.
37 changes: 37 additions & 0 deletions content/docs/data-modeling/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,43 @@ export const SalesByStageReport = {
`rows` are the pivot's down-axis dimensions; `values` are measure names. A matrix
report adds across-axis dimensions; `runtimeFilter` is the render-time scope.

## Ordering the result

A selected **date dimension is chronological by default** — ascending, on the
down axis and the across axis alike — so a month-bucketed matrix reads
left-to-right in time without declaring anything. Every other dimension keeps
the order the grouping produced until you ask for one.

To ask, declare `order`: a list of sort keys, most significant first. Each key
names a dimension the report groups by (`rows` / `columns`) or a measure it
displays (`values`); anything else is an authoring error rather than an ordering
that silently does nothing.

{/* os:check */}
```ts
export const HoursByOwnerReport = {
name: 'hours_by_owner',
label: 'Hours by Owner',
type: 'matrix',
dataset: 'tasks',
rows: ['owner'], // down axis
columns: ['closed_month'], // across axis — a month-bucketed date dimension
values: ['est_hours'],
order: [
{ by: 'closed_month', direction: 'desc' }, // newest month first (overrides the default)
{ by: 'est_hours', direction: 'desc' }, // then the biggest owner first
],
};
```

Ordering is applied **server-side over the whole grid** — after measure-scoped
filters merge and derived measures evaluate — so a derived measure is a valid
sort key even though no single SQL statement computes it. Null / empty buckets
sort last in both directions.

A `joined` report orders per block: put `order` on each `blocks[]` entry, not on
the container.

Placeholders inside a `filter` / `runtimeFilter` — `{date-macro}`s and the
session tokens `{current_user_id}` / `{current_org_id}` — are resolved on **both**
sides: by the renderer before it queries, and by the analytics dataset executor
Expand Down
18 changes: 16 additions & 2 deletions content/docs/references/ui/report.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ Report Type Enum
## TypeScript Usage

```typescript
import { JoinedReportBlock, Report, ReportChart, ReportType } from '@objectstack/spec/ui';
import type { JoinedReportBlock, Report, ReportChart, ReportType } from '@objectstack/spec/ui';
import { JoinedReportBlock, Report, ReportChart, ReportSort, ReportType } from '@objectstack/spec/ui';
import type { JoinedReportBlock, Report, ReportChart, ReportSort, ReportType } from '@objectstack/spec/ui';

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


---
Expand All @@ -58,6 +59,7 @@ const result = JoinedReportBlock.parse(data);
| **columns** | `string[]` | optional | Dimension names across (matrix) |
| **values** | `string[]` | optional | Measure names to show |
| **runtimeFilter** | `any` | optional | Render-time scope filter |
| **order** | `{ by: string; direction: Enum<'asc' \| 'desc'> }[]` | optional | Result ordering, most significant key first |
| **drilldown** | `boolean` | ✅ | Click-through to underlying records |
| **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 |
| **blocks** | `{ name: string; label?: string; description?: string; type: Enum<'tabular' \| 'summary' \| 'matrix'>; … }[]` | optional | Sub-reports for type=joined |
Expand Down Expand Up @@ -95,6 +97,18 @@ const result = JoinedReportBlock.parse(data);
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |


---

## ReportSort

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **by** | `string` | ✅ | Dimension or measure name to order by (must be selected by this report) |
| **direction** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction (default ascending) |


---

## ReportType
Expand Down
3 changes: 2 additions & 1 deletion docs/audits/2026-06-reportschema-property-liveness.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@

## 🔴 Obsolete sub-schemas (type-only re-exports, fully superseded by ADR-0021)
- `ReportColumnSchema` (`field/label/aggregate/responsive`) — replaced by plain `string[]` `values`; per-column aggregate/label now live in the **dataset** definition.
- `ReportGroupingSchema` (`field/sortOrder/dateGranularity`) — replaced by `string[]` `rows`; sort/granularity now in the **dataset**.
- `ReportGroupingSchema` (`field/sortOrder/dateGranularity`) — replaced by `string[]` `rows`; granularity now in the **dataset** (`dimensions[].dateGranularity`).
> **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.
- `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.

## Studio gap (PARTIAL)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Columns",
helpText: "Columns to display in the report"
},
order: {
label: "Order",
helpText: "Sort keys, most significant first (a rows/columns dimension or a values measure). Time dimensions are chronological by default."
},
drilldown: {
label: "Drilldown",
helpText: "Click an aggregated row/cell to open the underlying records"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "Columnas",
helpText: "Columnas que mostrar en el informe"
},
order: {
label: "Orden",
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."
},
drilldown: {
label: "Drilldown",
helpText: "Click an aggregated row/cell to open the underlying records"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "列",
helpText: "レポートに表示する列"
},
order: {
label: "並び順",
helpText: "ソートキー(優先度の高い順)。rows/columns のディメンション、または values のメジャーを指定します。時間ディメンションは既定で時系列順です。"
},
drilldown: {
label: "Drilldown",
helpText: "Click an aggregated row/cell to open the underlying records"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "列",
helpText: "报表中显示的列"
},
order: {
label: "排序",
helpText: "排序键,最重要的在前(rows/columns 维度或 values 度量)。时间维度默认按时间顺序排列。"
},
drilldown: {
label: "Drilldown",
helpText: "Click an aggregated row/cell to open the underlying records"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #3916 — a selected time dimension comes back CHRONOLOGICAL by default.
*
* The reported symptom was a matrix report whose date COLUMNS rendered in
* arbitrary order (`2026-07-01, 2026-07-05, …, 2026-07-02`). Nothing in the
* stack ordered them: `resolveOrdering` returned `undefined` unless the
* selection carried an explicit `order`, the ObjectQL aggregate path has no
* ordering grammar (its buckets come back in Map-insertion order), and the
* pivot builds its column headers in row-ARRIVAL order. Declaring
* `dateGranularity` made the keys sortable without making anything sort them.
*
* These tests pin the default and its boundaries: it applies to time dimensions
* only, it never overrides an explicit `order`, and it survives on both strategy
* paths — as a real `ORDER BY` where native SQL serves the query, and as the
* executor's post-pass where a bucketed query is handed to the ObjectQL path.
*/

import { describe, it, expect } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import { AnalyticsService } from '../analytics-service.js';
import { resolveOrdering } from '../dataset-executor.js';

const CTX = { tenantId: 'org_A' } as ExecutionContext;

/** The HotCRM shape: a date dimension across, a string dimension down. */
const tasks = DatasetSchema.parse({
name: 'task_metrics',
label: 'Tasks',
object: 'crm_task',
include: [],
dimensions: [
{ name: 'due_date', field: 'due_date', type: 'date' },
{ name: 'closed_month', field: 'closed_at', type: 'date', dateGranularity: 'month' },
{ name: 'owner', field: 'owner', type: 'string' },
],
measures: [
{ name: 'task_count', aggregate: 'count' },
{ name: 'est_hours', aggregate: 'sum', field: 'est_hours' },
],
});

/**
* ObjectQL-aggregate service — the path every date-BUCKETED query is handed to.
* Stub rows are keyed by the underlying FIELD name (`closed_at`), which is what
* the engine returns; the strategy remaps them onto the dimension names.
*/
function aggService(rows: Record<string, unknown>[]) {
return new AnalyticsService({
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
executeAggregate: async () => rows,
});
}

/** Native-SQL service; captures every statement it is asked to run. */
function sqlService(rows: Record<string, unknown>[], captured: string[] = []) {
const svc = new AnalyticsService({
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
executeRawSql: async (_o, sql) => { captured.push(sql); return rows; },
});
return { svc, captured };
}

describe('#3916 — resolveOrdering defaults the time axis to ascending', () => {
const selection = { measures: ['task_count'] };

it('orders a selected time dimension ascending when nothing else asked', () => {
expect(resolveOrdering(selection, ['closed_month'], ['closed_month'])).toEqual({ closed_month: 'asc' });
});

it('leaves a selection with no time dimension unordered, exactly as before', () => {
expect(resolveOrdering(selection, ['owner'], [])).toBeUndefined();
});

it('orders ONLY the time dimensions — a non-time dimension keeps the grouping order', () => {
// Deliberately narrow: sorting every dimension by default would reorder
// grids that have nothing wrong with them. The row axis stays as-is.
expect(resolveOrdering(selection, ['owner', 'closed_month'], ['closed_month']))
.toEqual({ closed_month: 'asc' });
});

it('keeps multiple time dimensions in selection order (first is the primary sort)', () => {
expect(resolveOrdering(selection, ['due_date', 'owner', 'closed_month'], ['due_date', 'closed_month']))
.toEqual({ due_date: 'asc', closed_month: 'asc' });
});

it('never overrides an explicit order — the default is a default, not a policy', () => {
expect(
resolveOrdering({ ...selection, order: { closed_month: 'desc' } }, ['closed_month'], ['closed_month']),
).toEqual({ closed_month: 'desc' });
});

it('yields to the bare-limit fallback, which orders EVERY dimension for a reproducible window', () => {
expect(resolveOrdering({ ...selection, limit: 10 }, ['owner', 'closed_month'], ['closed_month']))
.toEqual({ owner: 'asc', closed_month: 'asc' });
});

it('ignores a time dimension the selection does not actually group by', () => {
expect(resolveOrdering(selection, ['owner'], ['closed_month'])).toBeUndefined();
});
});

describe('#3916 — matrix date columns arrive chronological end-to-end', () => {
it('sorts month buckets the aggregate path emitted in insertion order', async () => {
// Exactly the reported grid: the driver's bucket Map hands back July before
// June before August, and nothing downstream fixed it.
const svc = aggService([
{ owner: 'ann', closed_at: '2026-07', task_count: 3 },
{ owner: 'ann', closed_at: '2026-06', task_count: 5 },
{ owner: 'bob', closed_at: '2026-08', task_count: 2 },
{ owner: 'bob', closed_at: '2026-06', task_count: 1 },
]);
const result = await svc.queryDataset(
tasks,
{ dimensions: ['owner', 'closed_month'], measures: ['task_count'] },
CTX,
);
// The pivot builds its column headers in row-ARRIVAL order, so the distinct
// months must arrive ascending for the across-axis to read left-to-right.
const months = [...new Set(result.rows.map((r) => r.closed_month))];
expect(months).toEqual(['2026-06', '2026-07', '2026-08']);
});

it('sorts quarter and year keys chronologically too (bucket keys are minted sort-stable)', async () => {
const quarterly = DatasetSchema.parse({
name: 'quarterly_tasks', label: 'Quarterly', object: 'crm_task', include: [],
dimensions: [{ name: 'closed_quarter', field: 'closed_at', type: 'date', dateGranularity: 'quarter' }],
measures: [{ name: 'task_count', aggregate: 'count' }],
});
const svc = aggService([
{ closed_at: '2026-Q3', task_count: 1 },
{ closed_at: '2025-Q4', task_count: 2 },
{ closed_at: '2026-Q1', task_count: 3 },
]);
const result = await svc.queryDataset(
quarterly,
{ dimensions: ['closed_quarter'], measures: ['task_count'] },
CTX,
);
expect(result.rows.map((r) => r.closed_quarter)).toEqual(['2025-Q4', '2026-Q1', '2026-Q3']);
});

it('emits a real ORDER BY when native SQL serves the query', async () => {
// An UNbucketed date dimension stays on the native path (native SQL declines
// granularity), so the ordering becomes server-side SQL rather than a
// post-pass — the "no ORDER BY in the aggregate SQL" half of the report.
const { svc, captured } = sqlService([{ due_date: '2026-07-01', task_count: 1 }]);
await svc.queryDataset(tasks, { dimensions: ['due_date'], measures: ['task_count'] }, CTX);
expect(captured[0]).toContain('ORDER BY "due_date" ASC');
});

it('adds no ORDER BY for a selection with no time dimension', async () => {
const { svc, captured } = sqlService([{ owner: 'ann', task_count: 1 }]);
await svc.queryDataset(tasks, { dimensions: ['owner'], measures: ['task_count'] }, CTX);
expect(captured[0]).not.toContain('ORDER BY');
});

it('an explicit desc order still wins end-to-end (newest month first)', async () => {
const svc = aggService([
{ closed_at: '2026-06', task_count: 5 },
{ closed_at: '2026-08', task_count: 2 },
{ closed_at: '2026-07', task_count: 3 },
]);
const result = await svc.queryDataset(
tasks,
{ dimensions: ['closed_month'], measures: ['task_count'], order: { closed_month: 'desc' } },
CTX,
);
expect(result.rows.map((r) => r.closed_month)).toEqual(['2026-08', '2026-07', '2026-06']);
});

it('keeps the empty (null) bucket last, as every other ordering does', async () => {
// #3839 made the empty bucket a real `null`; `compareValues` sorts nulls
// last in BOTH directions, so an undated row cannot lead the time axis.
const svc = aggService([
{ closed_at: null, task_count: 9 },
{ closed_at: '2026-07', task_count: 3 },
{ closed_at: '2026-06', task_count: 5 },
]);
const result = await svc.queryDataset(
tasks,
{ dimensions: ['closed_month'], measures: ['task_count'] },
CTX,
);
expect(result.rows.map((r) => r.closed_month)).toEqual(['2026-06', '2026-07', null]);
});
});
Loading
Loading