From 9a69b78c23ec0fa3970526152ea1e59d2f1207da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:22:41 +0000 Subject: [PATCH 1/2] feat(analytics): order the time axis by default, give reports a sort declaration (#3916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — an array because key order is the contract. `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. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CYJ5WSiCgd522s1zCWtmFT --- .../report-ordering-and-time-axis-default.md | 48 +++++ content/docs/data-modeling/analytics.mdx | 37 ++++ content/docs/references/ui/report.mdx | 18 +- .../2026-06-reportschema-property-liveness.md | 3 +- .../__tests__/dataset-time-axis-order.test.ts | 189 ++++++++++++++++++ .../service-analytics/src/dataset-executor.ts | 40 +++- packages/spec/api-surface.json | 4 + packages/spec/authorable-surface.json | 4 + packages/spec/json-schema.manifest.json | 1 + packages/spec/liveness/report.json | 6 + packages/spec/src/ui/report.form.ts | 5 + packages/spec/src/ui/report.test.ts | 85 ++++++++ packages/spec/src/ui/report.zod.ts | 121 ++++++++++- skills/objectstack-ui/SKILL.md | 11 + 14 files changed, 566 insertions(+), 6 deletions(-) create mode 100644 .changeset/report-ordering-and-time-axis-default.md create mode 100644 packages/services/service-analytics/src/__tests__/dataset-time-axis-order.test.ts diff --git a/.changeset/report-ordering-and-time-axis-default.md b/.changeset/report-ordering-and-time-axis-default.md new file mode 100644 index 0000000000..c0f97458b2 --- /dev/null +++ b/.changeset/report-ordering-and-time-axis-default.md @@ -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. diff --git a/content/docs/data-modeling/analytics.mdx b/content/docs/data-modeling/analytics.mdx index 8763923731..94099a556c 100644 --- a/content/docs/data-modeling/analytics.mdx +++ b/content/docs/data-modeling/analytics.mdx @@ -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 diff --git a/content/docs/references/ui/report.mdx b/content/docs/references/ui/report.mdx index 51e78ac3a5..7a699bf409 100644 --- a/content/docs/references/ui/report.mdx +++ b/content/docs/references/ui/report.mdx @@ -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); @@ -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 | --- @@ -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 | @@ -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 diff --git a/docs/audits/2026-06-reportschema-property-liveness.md b/docs/audits/2026-06-reportschema-property-liveness.md index a421ee5230..95826324bd 100644 --- a/docs/audits/2026-06-reportschema-property-liveness.md +++ b/docs/audits/2026-06-reportschema-property-liveness.md @@ -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) diff --git a/packages/services/service-analytics/src/__tests__/dataset-time-axis-order.test.ts b/packages/services/service-analytics/src/__tests__/dataset-time-axis-order.test.ts new file mode 100644 index 0000000000..dae8e3e12f --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/dataset-time-axis-order.test.ts @@ -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[]) { + 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[], 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]); + }); +}); diff --git a/packages/services/service-analytics/src/dataset-executor.ts b/packages/services/service-analytics/src/dataset-executor.ts index fe64d19fd3..8d2e06fae7 100644 --- a/packages/services/service-analytics/src/dataset-executor.ts +++ b/packages/services/service-analytics/src/dataset-executor.ts @@ -314,10 +314,26 @@ export function applyWindow( * When `limit`/`offset` is requested WITHOUT an order, the selected dimensions * ascending become the implicit ordering, so the truncated window is * reproducible instead of "whatever the group-by happened to emit". + * + * Failing both, a selected TIME dimension defaults to ASCENDING (#3916). A time + * axis has one order a reader expects — chronological — and until this default + * existed nothing supplied it anywhere in the stack: the 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. A month-bucketed matrix + * therefore rendered `2026-07-01, 2026-07-05, …, 2026-07-02`. Bucket keys are + * minted sort-stable for exactly this (`2026-07`, `2026-Q3`, `2026-W31`), so + * ascending IS chronological. An explicit `order` still wins outright — this is + * a default, not a policy — and non-time dimensions keep whatever order the + * grouping produced unless the caller asks. + * + * @param timeDimensions - The selected dimensions the cube types as `time`, in + * selection order. The executor resolves these (it owns the cube); passing + * them in keeps this function pure and directly testable. */ export function resolveOrdering( selection: DatasetSelection, dimensions: string[], + timeDimensions: string[] = [], ): Record | undefined { const order = selection.order; if (order && Object.keys(order).length > 0) { @@ -340,6 +356,11 @@ export function resolveOrdering( if ((selection.limit != null || selection.offset != null) && dimensions.length > 0) { return Object.fromEntries(dimensions.map((d) => [d, 'asc' as const])); } + // #3916 — chronological by default on the time axis. + const timeKeys = timeDimensions.filter((d) => dimensions.includes(d)); + if (timeKeys.length > 0) { + return Object.fromEntries(timeKeys.map((d) => [d, 'asc' as const])); + } return undefined; } @@ -476,8 +497,9 @@ export class DatasetExecutor { const dimensions = selection.dimensions ?? []; // Effective ordering — validated against what this selection projects, with - // a deterministic dimension order synthesized for a bare `limit` (#3588). - const order = resolveOrdering(selection, dimensions); + // a deterministic dimension order synthesized for a bare `limit` (#3588) and + // an ascending default on the time axis (#3916). + const order = resolveOrdering(selection, dimensions, this.timeDimensionsOf(compiled, dimensions)); // #3680 — order keys naming a select/lookup dimension sort by the DISPLAY // label the response will carry, not the stored value / FK id. Resolved @@ -575,6 +597,20 @@ export class DatasetExecutor { return result; } + /** + * The selected dimensions the compiled cube types as `time`, in selection + * order (#3916) — the axis {@link resolveOrdering} defaults to ascending. + * + * Membership is decided by the DIMENSION's declared type, not by whether the + * selection happens to bucket it: a `date` dimension left ungranulated groups + * raw timestamps, and those want chronological order every bit as much as + * month buckets do. (Both sort correctly — `compareValues` compares Dates and + * ISO strings chronologically, and bucket keys are minted sort-stable.) + */ + private timeDimensionsOf(compiled: CompiledDataset, dimensions: string[]): string[] { + return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === 'time'); + } + private buildQuery( compiled: CompiledDataset, opts: { diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 7b84531b82..5cff11e975 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3369,6 +3369,9 @@ "ReportNavItem (type)", "ReportNavItemSchema (const)", "ReportSchema (const)", + "ReportSort (type)", + "ReportSortInput (type)", + "ReportSortSchema (const)", "ReportType (const)", "ResolvedActionParam (interface)", "ResponsiveConfig (type)", @@ -3482,6 +3485,7 @@ "normalizeFilterOperator (function)", "pageForm (const)", "reportForm (const)", + "reportSelectionOrder (function)", "validateActionParams (function)", "viewForm (const)" ], diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index cb696817e5..a1a9cbe4bc 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -8016,6 +8016,7 @@ "ui/JoinedReportBlock:description", "ui/JoinedReportBlock:label", "ui/JoinedReportBlock:name", + "ui/JoinedReportBlock:order", "ui/JoinedReportBlock:rows", "ui/JoinedReportBlock:runtimeFilter", "ui/JoinedReportBlock:type", @@ -8379,6 +8380,7 @@ "ui/Report:drilldown", "ui/Report:label", "ui/Report:name", + "ui/Report:order", "ui/Report:protection", "ui/Report:rows", "ui/Report:runtimeFilter", @@ -8410,6 +8412,8 @@ "ui/ReportNavItem:requiresService", "ui/ReportNavItem:type", "ui/ReportNavItem:visible", + "ui/ReportSort:by", + "ui/ReportSort:direction", "ui/ResponsiveConfig:breakpoint", "ui/ResponsiveConfig:columns", "ui/ResponsiveConfig:hiddenOn", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 8b9d1c0aef..1f564f0153 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1724,6 +1724,7 @@ "ui/Report", "ui/ReportChart", "ui/ReportNavItem", + "ui/ReportSort", "ui/ReportType", "ui/ResponsiveConfig", "ui/ResponsiveStyles", diff --git a/packages/spec/liveness/report.json b/packages/spec/liveness/report.json index 437b2a1144..baff10c81e 100644 --- a/packages/spec/liveness/report.json +++ b/packages/spec/liveness/report.json @@ -38,6 +38,12 @@ "status": "live", "note": "objectui: render-time scope filter ANDed at query time (`?? filter`, mergeFilters) — DatasetReportRenderer." }, + "order": { + "status": "planned", + "authorWarn": true, + "authorHint": "A selected date dimension is ALREADY chronological (ascending) without `order` — that default is live server-side. Only the explicit declaration is pending its renderer wiring.", + "note": "[planned] framework#3916. The FRAMEWORK half is complete and live: the schema accepts `order`, `reportSelectionOrder` (ui/report.zod.ts) lowers it to `DatasetSelection.order`, and the executor applies that ordering over the assembled grid (services/service-analytics/src/dataset-executor.ts, resolveOrdering/applyOrdering). What is NOT yet wired is the objectui side — `DatasetReportRenderer` builds the selection it posts and does not carry `report.order` into it, so an authored `order` does not reach the server yet. Marked `planned` + authorWarn until objectui lowers it, per enforce-or-mark. NOTE the separate, already-live default this issue also shipped: a selected TIME dimension defaults to ascending server-side, so month/quarter matrix columns are chronological with no `order` declared at all." + }, "drilldown": { "status": "live", "note": "objectui: DatasetReportRenderer.tsx:697 — click-through to underlying records (default on; set false to disable)." diff --git a/packages/spec/src/ui/report.form.ts b/packages/spec/src/ui/report.form.ts index 3113104a25..4591a38d75 100644 --- a/packages/spec/src/ui/report.form.ts +++ b/packages/spec/src/ui/report.form.ts @@ -41,6 +41,11 @@ export const reportForm = defineForm({ { field: 'rows', widget: 'string-tags', helpText: 'Dimension names (from the dataset) to group rows by' }, // CEL visibility — only Matrix reports pivot across a second dimension. { field: 'columns', widget: 'string-tags', visibleWhen: "data.type == 'matrix'", helpText: 'Dimension names across (matrix only)' }, + // #3916 — ordering is authorable here or it is not authorable at all: + // `DatasetSelection.order` has always existed on the wire, but a report + // author had no channel to reach it. Optional — a selected time + // dimension is chronological by default without declaring anything. + { field: 'order', type: 'repeater', helpText: 'Sort keys, most significant first (a rows/columns dimension or a values measure). Time dimensions are chronological by default.' }, { field: 'drilldown', helpText: 'Click an aggregated row/cell to open the underlying records' }, ], }, diff --git a/packages/spec/src/ui/report.test.ts b/packages/spec/src/ui/report.test.ts index 49325ca214..99d8181433 100644 --- a/packages/spec/src/ui/report.test.ts +++ b/packages/spec/src/ui/report.test.ts @@ -4,9 +4,11 @@ import { describe, it, expect } from 'vitest'; import { ReportSchema, ReportChartSchema, + ReportSortSchema, ReportType, Report, JoinedReportBlockSchema, + reportSelectionOrder, } from './report.zod'; /** @@ -96,6 +98,89 @@ describe('Joined reports', () => { }); }); +/** + * #3916 — reports can declare an ordering. Before this the report schema had no + * sort field at all: `DatasetSelection.order` existed but was unreachable for + * report authors (dashboard widgets had their own `options.sortBy` channel), + * so a matrix report's date columns rendered in whatever order the rows arrived. + */ +describe('Report ordering (#3916)', () => { + it('accepts an order over a dimension and a measure, direction defaulting to asc', () => { + const r = ReportSchema.parse({ + name: 'hours_matrix', label: 'Hours', type: 'matrix', + dataset: 'tasks', rows: ['owner'], columns: ['closed_month'], values: ['est_hours'], + order: [{ by: 'closed_month' }, { by: 'est_hours', direction: 'desc' }], + }); + expect(r.order).toEqual([ + { by: 'closed_month', direction: 'asc' }, + { by: 'est_hours', direction: 'desc' }, + ]); + }); + + it('is optional — a report without it stays valid (the runtime still defaults the time axis)', () => { + const r = ReportSchema.parse({ + name: 'rep_x', label: 'R', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], + }); + expect(r.order).toBeUndefined(); + }); + + it('rejects a key the report does not select — the mistyped-sort failure mode', () => { + expect(() => ReportSchema.parse({ + name: 'rep_x', label: 'R', type: 'summary', + dataset: 'sales', rows: ['stage'], values: ['revenue'], + order: [{ by: 'closed_month' }], + })).toThrow(/not selected by this report/); + }); + + it('rejects a duplicated key', () => { + expect(() => ReportSchema.parse({ + name: 'rep_x', label: 'R', type: 'summary', + dataset: 'sales', rows: ['stage'], values: ['revenue'], + order: [{ by: 'stage' }, { by: 'stage', direction: 'desc' }], + })).toThrow(/duplicate order key/); + }); + + it('a joined report orders per block, not at the container', () => { + const blocks = [ + { name: 'open_block', type: 'summary', dataset: 'tasks', rows: ['status'], values: ['task_count'], order: [{ by: 'task_count', direction: 'desc' }] }, + ]; + const r = ReportSchema.parse({ name: 'overview', label: 'Overview', type: 'joined', blocks }); + expect(r.blocks[0].order).toEqual([{ by: 'task_count', direction: 'desc' }]); + expect(() => ReportSchema.parse({ + name: 'overview', label: 'Overview', type: 'joined', blocks, order: [{ by: 'task_count' }], + })).toThrow(/orders per block/); + }); + + it('a block order key is validated against that block\'s own selection', () => { + expect(() => JoinedReportBlockSchema.parse({ + name: 'blk_x', type: 'summary', dataset: 'tasks', rows: ['status'], values: ['task_count'], + order: [{ by: 'revenue' }], + })).toThrow(/not selected by this report/); + }); + + it('ReportSortSchema defaults direction to asc', () => { + expect(ReportSortSchema.parse({ by: 'stage' })).toEqual({ by: 'stage', direction: 'asc' }); + }); + + it('reportSelectionOrder lowers the list to DatasetSelection.order, keys in list order', () => { + const lowered = reportSelectionOrder([ + { by: 'closed_month' }, + { by: 'revenue', direction: 'desc' }, + ]); + expect(lowered).toEqual({ closed_month: 'asc', revenue: 'desc' }); + // Key ORDER is the contract — it is how sort significance survives the + // lowering into a plain object. + expect(Object.keys(lowered!)).toEqual(['closed_month', 'revenue']); + }); + + it('reportSelectionOrder returns undefined for an absent or empty order', () => { + // Not `{}` — the caller must omit the field so the runtime's own defaults + // (chronological time axis) still apply. + expect(reportSelectionOrder(undefined)).toBeUndefined(); + expect(reportSelectionOrder([])).toBeUndefined(); + }); +}); + describe('ReportChartSchema', () => { it('requires xAxis + yAxis', () => { expect(ReportChartSchema.parse({ type: 'bar', xAxis: 'stage', yAxis: 'revenue' }).type).toBe('bar'); diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 09b9d09eec..247dc988dc 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -34,6 +34,84 @@ export const ReportChartSchema = lazySchema(() => ChartConfigSchema.extend({ yAxis: z.string().describe('Dataset measure name for the Y-axis (bound-dataset measure, not a raw field)'), })); +/** + * Report Sort Schema (framework#3916) + * + * One ordering key of a report's `order` list. `by` names something the report + * actually SELECTS — a `rows`/`columns` dimension or a `values` measure — which + * is validated at authoring time rather than at render time, because a mistyped + * sort key that quietly returns arbitrarily-ordered rows is the exact failure + * this exists to remove. + * + * An ARRAY (not a `Record`) because a report's ordering is + * multi-key and its key order is significant, and JSON object key order is not + * a contract an author should have to rely on. The renderer lowers the list to + * `DatasetSelection.order` in list order — see {@link reportSelectionOrder}. + */ +export const ReportSortSchema = lazySchema(() => z.object({ + /** A dimension (`rows`/`columns`) or measure (`values`) name this report selects. */ + by: z.string().describe('Dimension or measure name to order by (must be selected by this report)'), + /** Sort direction. Null/empty cells sort LAST in both directions. */ + direction: z.enum(['asc', 'desc']).default('asc').describe('Sort direction (default ascending)'), +})); + +/** + * Validate a report/block's `order` against what it selects, in place. + * + * Shared by `ReportSchema` and `JoinedReportBlockSchema` so a block's ordering + * is held to the same contract as a top-level report's. + */ +function checkReportOrder( + r: { order?: Array<{ by: string }>; rows?: string[]; columns?: string[]; values?: string[] }, + ctx: z.RefinementCtx, +): void { + if (!r.order?.length) return; + const selectable = new Set([...(r.rows ?? []), ...(r.columns ?? []), ...(r.values ?? [])]); + const seen = new Set(); + for (const key of r.order) { + if (seen.has(key.by)) { + ctx.addIssue({ + code: 'custom', + message: `duplicate order key "${key.by}" — each dimension/measure may be ordered once.`, + path: ['order'], + }); + } + seen.add(key.by); + if (!selectable.has(key.by)) { + ctx.addIssue({ + code: 'custom', + message: + `order key "${key.by}" is not selected by this report — ` + + `name a \`rows\`/\`columns\` dimension or a \`values\` measure. ` + + `Selectable here: ${[...selectable].join(', ') || '(none)'}.`, + path: ['order'], + }); + } + } +} + +/** + * Lower a report's authored `order` into the `DatasetSelection.order` a + * preview/query request posts (framework#3916). + * + * The array's element order becomes the object's key insertion order, which is + * how `DatasetSelection.order` expresses sort significance (first key = primary + * sort). Returns `undefined` for an absent or empty list so the caller omits + * the field entirely and the runtime's own defaults apply — a selected time + * dimension still comes back chronological without the author asking. + * + * Duplicate keys are rejected by the schema, so the last-write-wins collapse an + * object build would otherwise hide cannot reach here from validated metadata. + */ +export function reportSelectionOrder( + order?: Array<{ by: string; direction?: 'asc' | 'desc' }>, +): Record | undefined { + if (!order?.length) return undefined; + const out: Record = {}; + for (const key of order) out[key.by] = key.direction ?? 'asc'; + return out; +} + /** * Joined Report Block Schema * @@ -80,7 +158,9 @@ export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => z.object({ values: z.array(z.string()).optional().describe('Measure names to show (dataset-bound)'), /** Render-time scope filter, ANDed at query time. Dataset-bound only. */ runtimeFilter: FilterConditionSchema.optional().describe('Render-time scope filter (dataset-bound)'), -})); + /** Result ordering for this block, most significant key first (framework#3916). */ + order: z.array(ReportSortSchema).optional().describe('Result ordering, most significant key first'), +}).superRefine(checkReportOrder)); /** * Report Schema @@ -115,6 +195,31 @@ export const ReportSchema = lazySchema(() => z.object({ values: z.array(z.string()).optional().describe('Measure names to show'), /** Render-time scope filter, ANDed at query time. */ runtimeFilter: FilterConditionSchema.optional().describe('Render-time scope filter'), + /** + * Result ordering — most significant key first (framework#3916). + * + * Each key names a dimension this report groups by (`rows` or `columns`) or a + * measure it displays (`values`); anything else is an authoring-time error. + * The renderer lowers this to `DatasetSelection.order` via + * {@link reportSelectionOrder}, so the ordering is applied SERVER-SIDE over + * the whole grid — after measure-scoped filters merge and derived measures + * evaluate — not by the pivot over whatever rows happened to arrive. + * + * For a `matrix` report the ordering drives BOTH axes: a key naming a + * `columns` dimension orders the across-axis headers, a key naming a `rows` + * dimension orders the down-axis groups. List the columns key first when the + * across-axis order is the one that matters (the header sequence then follows + * the primary sort exactly). + * + * Ordering is OPTIONAL, not required for a sane result: a selected date/time + * dimension already defaults to ascending (chronological) server-side, so a + * month-bucketed matrix reads left-to-right in time without this field. + * Declare `order` to sort by a measure ("biggest region first"), to reverse a + * time axis (newest first), or to order a non-time dimension. + * + * A `joined` report orders per block — see `blocks[].order`. + */ + order: z.array(ReportSortSchema).optional().describe('Result ordering, most significant key first'), /** * ADR-0021 D2 — click an aggregated row/cell to open the underlying * records (dataset-backed; the host resolves the dataset's object and @@ -166,6 +271,18 @@ export const ReportSchema = lazySchema(() => z.object({ path: ['dataset'], }); } + // A `joined` report selects nothing itself — its ordering lives per block. + if (r.type === 'joined') { + if (r.order?.length) { + ctx.addIssue({ + code: 'custom', + message: 'a `joined` report orders per block — move `order` onto `blocks[]`.', + path: ['order'], + }); + } + } else { + checkReportOrder(r, ctx); + } })); export type JoinedReportBlock = z.infer; @@ -179,6 +296,7 @@ export type JoinedReportBlockInput = z.input; */ export type Report = z.infer; export type ReportChart = z.infer; +export type ReportSort = z.infer; /** * Input Types for Report Configuration @@ -186,6 +304,7 @@ export type ReportChart = z.infer; */ export type ReportInput = z.input; export type ReportChartInput = z.input; +export type ReportSortInput = z.input; /** * Report Factory Helper diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index 28a4319403..afa474c37b 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -704,6 +704,10 @@ export const PipelineCoverageReport = defineReport({ columns: ['close_date'], // across axis (ADR-0021 D2) — matrix pivots rows × columns values: ['amount_sum'], // measures placed in the cells runtimeFilter: { stage: { $ne: 'closed_lost' } }, + // Optional ordering, most significant key first. A selected DATE dimension is + // already chronological by default — declare `order` only to change that, or + // to sort by a measure / a non-date dimension. + order: [{ by: 'amount_sum', direction: 'desc' }], // drilldown defaults true — click a cell to open the underlying records; set false to disable. chart: { type: 'bar', xAxis: 'forecast_category', yAxis: 'amount_sum' }, }); @@ -719,6 +723,13 @@ export const PipelineCoverageReport = defineReport({ > put both axes in `rows`. Multi-level grouping on either axis = multiple > dimension names in that array. `drilldown` (default `true`) makes cells > click-through to the underlying records. +> **`order`** sorts the result server-side — a list of `{ by, direction }`, most +> significant key first. `by` must be a `rows`/`columns` dimension or a `values` +> measure the report actually selects (anything else is an authoring error). A +> selected date dimension already defaults to ASCENDING, so a month-bucketed +> matrix reads left-to-right in time with no `order` at all; list the `columns` +> key first when the across-axis header sequence is what matters. A `joined` +> report orders per block (`blocks[].order`), never on the container. --- From a27b6dbf988a11a31325a08097dd2a8c0bfae1a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:43:49 +0000 Subject: [PATCH 2/2] fix(i18n): regenerate the metadata-form bundles the new report `order` field drifted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:i18n` failed on the PR: the `order` field added to `reportForm` carries a label + helpText, and the platform-objects metadata-form bundles are GENERATED from the form declarations, so all four locales drifted from a fresh extract. Regenerated via `scripts/check-i18n-bundles.mjs --write`; the diff is exactly the one new `report.order` entry per locale, nothing else. The zh-CN / ja-JP / es-ES entries are hand-translated rather than left as the merge-mode English filler. Not cosmetic: platform-objects sits at 0 in scripts/i18n-coverage-baseline.json, so `check:i18n-coverage` is already the strict gate for this package and a new untranslated declared label would have turned the next run red. Verified both ways — `check:i18n` reports 8 bundles in sync (merge mode preserves the hand translations), and `os lint --json` reports 0 i18n issues for the config. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CYJ5WSiCgd522s1zCWtmFT --- .../src/apps/translations/en.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/es-ES.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 565a545d19..f38f2e326a 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1232,6 +1232,10 @@ export const enMetadataForms: NonNullable = { 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" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 4294acb3f2..343854d713 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1232,6 +1232,10 @@ export const esESMetadataForms: NonNullable = 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" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 78a143edfb..62371bb6cc 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1232,6 +1232,10 @@ export const jaJPMetadataForms: NonNullable = label: "列", helpText: "レポートに表示する列" }, + order: { + label: "並び順", + helpText: "ソートキー(優先度の高い順)。rows/columns のディメンション、または values のメジャーを指定します。時間ディメンションは既定で時系列順です。" + }, drilldown: { label: "Drilldown", helpText: "Click an aggregated row/cell to open the underlying records" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index c9f74b856d..d95aa19e65 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1232,6 +1232,10 @@ export const zhCNMetadataForms: NonNullable = label: "列", helpText: "报表中显示的列" }, + order: { + label: "排序", + helpText: "排序键,最重要的在前(rows/columns 维度或 values 度量)。时间维度默认按时间顺序排列。" + }, drilldown: { label: "Drilldown", helpText: "Click an aggregated row/cell to open the underlying records"