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
21 changes: 21 additions & 0 deletions .changeset/26548-report-date-range-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
'@objectstack/platform-objects': patch
---

feat(spec): a report declares the period it covers (`dateRange`) and the export of its table (`exportOptions`)

Clause-②: yes (widening)
Metadata-protocol: added

`ReportSchema` gains two optional top-level keys. A report that declares neither parses and renders exactly as before.

- **`dateRange: { dimension?, defaultRange, allowCustomRange }`.** A date window over one dimension of the report's dataset. `defaultRange` and `allowCustomRange` are the dashboard's own declaration, verbatim: the same preset table (`DATE_RANGE_DEFAULT_RANGES`), the same defaults (`this_month`, `true`) and the same rename hints. `dimension` names a dataset **dimension** that declares `type: 'date'`, not a raw object field; omitted, the dataset's one `type: 'date'` dimension is used. The window is a filter, not a bucket. The renderer sends it as `selection.timeDimensions: [{ dimension, dateRange }]` with no `granularity`, which the analytics service already accepts. `dateFilter` / `timeRange` are renamed onto `dateRange`, the dashboard's own spellings.
- **`exportOptions: { formats?, includeHeaders?, fileNamePrefix? }`.** Three members of the list export block, under its key name. `formats` is `csv` / `xlsx` only (`json` and `pdf` are refused with a report-side message). Export is on by default (both formats) and `formats: []` turns it off. `maxRecords` and `streaming` are list-only and are refused with that reason. There is no report permission key: the control is gated on the dataset base object's `ObjectPermission.allowExport`, the axis the list toolbar uses. `allowExport` written on a report is answered with that pointer.
- **Refused on a `joined` report**, on the container and on a block. The blocks may bind different datasets, and the report draws one table per block.
- **No subscription.** `subscription` / `schedule` on a report are answered: there is no scheduled delivery of a report.

`@objectstack/lint` adds the dataset-aware half as a reference-integrity rule, `validateReportDateRange`. It runs on `os validate`, `os lint` and `os build`, and at the report publish door. Four `error` verdicts, each with an `os explain` entry: `report-date-range-dimension-unknown`, `report-date-range-dimension-not-date`, `report-date-range-dimension-ambiguous` (dimension omitted, two or more date dimensions) and `report-date-range-dimension-none` (dimension omitted, no date dimension). The liveness author-warning lint now walks `reports`, and both keys warn (`liveness-planned-property`) until the Console report page reads them. The report editor form gains a collapsed "Period & export" section, translated in `@objectstack/platform-objects` for zh-CN, ja-JP and es-ES.

Nothing renders either key yet. The Console report page's period chip and export control are the renderer's half, and until it lands an author declaring either key gets a planned-property warning that says so.
18 changes: 18 additions & 0 deletions content/docs/references/ui/report.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ const result = JoinedReportBlockSchema.parse(data);
| **order** | `{ by: string; direction?: Enum<'asc' \| 'desc'> }[]` | optional | Result ordering, most significant key first |
| **drilldown** | `boolean` | optional (default: `true`) | Click-through to underlying records |
| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; title?: string \| Record<string, string>; subtitle?: string \| Record<string, string>; description?: string \| Record<string, string>; … }` | optional | Embedded chart configuration (refused on a joined report, which draws tables only) |
| **dateRange** | `{ dimension?: string; defaultRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>; allowCustomRange?: boolean }` | optional | Report date range: a window over one dataset date dimension (refused on a joined report) |
| **exportOptions** | `{ formats?: Enum<'csv' \| 'xlsx'>[]; includeHeaders?: boolean; fileNamePrefix?: string }` | optional | Report export control: `{ formats?, includeHeaders?, fileNamePrefix? }`, csv / xlsx, on by default; who may export is the base object's export permission (refused on a joined report) |
| **blocks** | `{ name: string; label?: string \| Record<string, string>; description?: string \| Record<string, string>; type?: Enum<'tabular' \| 'summary' \| 'matrix'>; … }[]` | optional | Sub-reports for type=joined |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this report. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
Expand Down Expand Up @@ -103,6 +105,22 @@ const result = JoinedReportBlockSchema.parse(data);
| **interaction** | `{ tooltips?: boolean; brush?: boolean }` | optional | Interaction toggles: `{ tooltips?, brush? }` |
| **aria** | `never` | optional | [REMOVED] `ChartConfig.aria` — authored as `dashboard.widgets[].chartConfig.aria`, `report.chart.aria` and `report.blocks[].chart.aria` — was removed in @objectstack/spec 17 (ADR-0049 D2). No chart renderer ever applied it: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react `<ObjectChart>` block never published it, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied on this same chart config is its sibling `description`, which the chart renderer lowers onto the chart graphic as `role="img"` plus `aria-label`. The shared `AriaProps` shape is NOT gone — `ariaLabel` / `ariaDescribedBy` / `role` stay live in the `aria` block on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |

### Nested Shape: `Report.dateRange`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dimension** | `string` | optional | Dataset dimension (declared `type: 'date'`) the window applies to; omitted = the dataset's one date dimension |
| **defaultRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>` | optional (default: `"this_month"`) | Default date range preset |
| **allowCustomRange** | `boolean` | optional (default: `true`) | Allow users to pick a custom date range |

### Nested Shape: `Report.exportOptions`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **formats** | `Enum<'csv' \| 'xlsx'>[]` | optional | Formats the report's export control offers (default: ['csv', 'xlsx']); an empty list turns the control off |
| **includeHeaders** | `boolean` | optional | Include column headers in the exported file (default true) |
| **fileNamePrefix** | `string` | optional | Download file name prefix — replaces the report label in the generated file name |

### Nested Shape: `Report.blocks[number]`

| Property | Type | Required | Description |
Expand Down
65 changes: 65 additions & 0 deletions content/docs/ui/reports.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,67 @@ export const HoursByStatusChartReport = defineReport({
});
```

## Period and export

A non-`joined` report can declare the period it covers and how its table is downloaded.
Both keys are optional: a report that declares neither shows every period, as it always
has.

{/* os:check */}
```typescript
import { defineReport } from '@objectstack/spec/ui';

export const PipelineByQuarterReport = defineReport({
name: 'pipeline_by_quarter',
label: 'Pipeline by Quarter',
type: 'summary',
dataset: 'opportunity_metrics',
rows: ['stage'],
values: ['amount_sum'],
dateRange: { dimension: 'close_date', defaultRange: 'this_quarter' },
exportOptions: { formats: ['csv', 'xlsx'], fileNamePrefix: 'pipeline' },
});
```

**`dateRange`** is a window over one **date dimension of the dataset**. `defaultRange` and
`allowCustomRange` are the dashboard's own: the same preset names (`this_month` by default,
`this_quarter`, `last_30_days`, …, and `custom` to open the picker with no preset) and the
same custom-range switch, on by default.

- **`dimension`** names a dataset dimension that declares `type: 'date'` — a dimension, not a
raw object field. A dimension carries its own relationship path, so a date on a related
record works the same way.
- **Leave `dimension` out** only when the dataset declares exactly one `type: 'date'`
dimension. A dataset with none, or with two or more (a task dataset with `due_date` and
`completed_date`, say), is an error that names what it found. So is a `dimension` the
dataset does not declare, or one that is not `type: 'date'`. `os validate` and `os build`
check this against the dataset, and so does publishing the report.
- **The window is a filter, not a bucket.** It is applied with the report's `runtimeFilter`
and the dataset's own filter. How rows are grouped by date stays with the dimension's
`dateGranularity` and the report's `rows` / `columns`.

**`exportOptions`** uses the list export's key and member names: `formats`,
`includeHeaders`, `fileNamePrefix`. A report table downloads as `csv` or `xlsx`. Export is on
by default (both formats), and `formats: []` turns it off. The download is the table as drawn,
within the active period and filter, and the file is named for the report label (or
`fileNamePrefix`) and the period. `maxRecords` and `streaming` are list-only, because a report's
rows are already aggregated.

**Who may export is not a report key.** The export control is offered to a viewer whose
permissions allow **export of the dataset's base object**. That is the permission-set
`allowExport` axis the list toolbar uses (see [Permissions and protection](#permissions-and-protection)).

<Callout type="warn">
**Declared, not drawn yet.** The Console report page does not read either key yet: there is
no period chip and no export control on it today. Until the renderer ships, `os validate`
warns on both keys so the gap is visible. The declarations are the contract the renderer
implements.
</Callout>

What is deliberately **not** offered: a `joined` report refuses both keys, because its blocks
may bind different datasets and it draws one table per block. A report has no subscription or
scheduled delivery, and no `pdf` or `json` export.

## Making a report reachable

A report is not reachable because it exists. Give it a navigation entry on an app:
Expand Down Expand Up @@ -256,6 +317,10 @@ never on the report — and **reachability** comes from the app navigation entry
permissions on the app that carries it. A report is a presentation over a dataset, so it
inherits the dataset's enforcement rather than restating it.

Export follows the same principle. A report declares *what* its export offers
(`exportOptions`), never *who* may use it: that is the permission-set `allowExport` grant on
the dataset's base object, which also governs exporting that object's list views.

## Related

- **Schema reference:** [Report](/docs/references/ui/report) — the full generated property
Expand Down
10 changes: 5 additions & 5 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 216 | 205 | 4 | 0 | 7 |
| `ui/` | 217 | 206 | 4 | 0 | 7 |

## `ui/` — sites

Expand All @@ -41,28 +41,28 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `dataset.zod.ts` | 5 |
| `i18n.zod.ts` | 1 |
| `page.zod.ts` | 12 |
| `report.zod.ts` | 3 |
| `report.zod.ts` | 4 |
| `responsive.zod.ts` | 1 |
| `sharing.zod.ts` | 1 |
| `view.zod.ts` | 59 |
| `widget.zod.ts` | 1 |
| **total** | **216** |
| **total** | **217** |

## `ui/` — open

Per file, how many of its sites still silently discard unknown keys. The `Class`
column that decides the bucket split is hand-written in the ledger; the arithmetic
over it is here.

**7 strip of 216**, in 4 file(s).
**7 strip of 217**, in 4 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 19 |
| `view.zod.ts` | 4 | 59 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **7** | **216** |
| **total** | **7** | **217** |

| Bucket | Sites |
|---|---|
Expand Down
12 changes: 12 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,18 @@ export {
} from './validate-chart-bindings.js';
export type { ChartBindingFinding, ChartBindingSeverity } from './validate-chart-bindings.js';

// [#26548] A report's `dateRange` resolved against its dataset: the one
// `type: 'date'` dimension the period applies to. Beside the chart rule
// because it resolves the same report against the same `stack.datasets`.
export {
validateReportDateRange,
REPORT_DATE_RANGE_DIMENSION_UNKNOWN,
REPORT_DATE_RANGE_DIMENSION_NOT_DATE,
REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS,
REPORT_DATE_RANGE_DIMENSION_NONE,
} from './validate-report-date-range.js';
export type { ReportDateRangeFinding } from './validate-report-date-range.js';

// [#14105] The layer BELOW the chart/widget binding rules above: a dataset's
// own `include[]`, `dimensions[].field`, `measures[].field` and filter KEYS,
// resolved against the object graph. Its base-object half is
Expand Down
27 changes: 27 additions & 0 deletions packages/lint/src/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2287,3 +2287,30 @@ describe('#26146 — ledger rows below the child level are graded and counted',
expect(shipped.has('synth-depth-three')).toBe(false);
});
});

// ── #26548: a report's period and export are declared before the renderer reads them ──
//
// `report` joined TYPE_COLLECTIONS with its ledger's first warned rows:
// `dateRange` and `exportOptions` are `planned` + `authorWarn` until the
// Console report page reads them. Read off the SHIPPED ledger, so the flip to
// `live` (which drops `authorWarn`) turns the warning half of this block red
// on purpose — rewrite it as a silence pin in that same edit.
describe('report period and export (#26548 — planned until the report renderer reads them)', () => {
const report = (extra: Record<string, unknown> = {}) => ({
reports: [{ name: 'pipeline', label: 'Pipeline', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], ...extra }],
});

it('warns on each key, at the report, with the row\'s author hint', () => {
const findings = lintLivenessProperties(report({ dateRange: { dimension: 'close_date' }, exportOptions: { formats: ['csv'] } }));
const planned = findings.filter((f) => f.rule === 'liveness-planned-property');
expect(planned.map((f) => f.message.match(/`(dateRange|exportOptions)`/)?.[1]).sort()).toEqual(['dateRange', 'exportOptions']);
for (const f of planned) {
expect(f.where).toContain("report 'pipeline'");
expect(f.hint).toMatch(/^Keep it, but nothing reads it yet: the Console report page draws no /);
}
});

it('CONTROL — a report declaring neither key draws no warning', () => {
expect(lintLivenessProperties(report())).toEqual([]);
});
});
5 changes: 5 additions & 0 deletions packages/lint/src/lint-liveness-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,11 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [
// lookup, and it means a future regression that re-deadens a widget key warns
// on its own instead of waiting for someone to notice this list again.
{ type: 'dashboard', key: 'dashboards' },
// #26548 — `report` joins the day its ledger first warns: `dateRange` and
// `exportOptions` are `planned` + `authorWarn` until the report renderer
// reads them. A flat collection whose top-level keys are the ledger's props,
// so the flat walk below is the right shape for it.
{ type: 'report', key: 'reports' },
];

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ describe('reference-integrity suite — membership', () => {
// Placed beside the member that resolves the same page's field names.
'validatePageVisualizationBindings',
'validateChartBindings',
// [#26548] The same report's `dateRange.dimension`, resolved against the
// same dataset's date dimensions — placed beside the chart member that
// resolves the rest of that report's bindings.
'validateReportDateRange',
// [#14105] The dataset's OWN references, one level below the two binding
// rules above it — the conscious edit this written-out list exists to
// force, placed so the family reports top-down.
Expand Down
18 changes: 18 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import { validateActionDispatchContract } from './validate-action-dispatch-contr
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validatePageVisualizationBindings } from './validate-page-visualization-bindings.js';
import { validateChartBindings } from './validate-chart-bindings.js';
import { validateReportDateRange } from './validate-report-date-range.js';
import { validateDatasetReferences } from './validate-dataset-references.js';
import { validateMappingTargetFields } from './validate-mapping-target-fields.js';
import { validateNavAccess } from './validate-nav-access.js';
Expand Down Expand Up @@ -438,6 +439,23 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// crossing: 9 reports (showcase 4, todo 5), 8 of them binding a real dataset
// with `rows`/`values` — 0 findings, with a lit synthetic probe refused.
{ name: 'validateChartBindings', runtimeTypes: ['flow', 'report'], run: validateChartBindings },
// [#26548] The same report, one key on: `dateRange.dimension` resolved
// against the bound dataset's `type: 'date'` dimensions (named, or the ONE
// candidate when omitted). Directly after its sibling because the two read
// the same `stack.reports` against the same `stack.datasets`, and this one
// skips a dataset that resolves to nothing — the sibling's
// `chart-dataset-unknown` reports that typo once.
//
// `report` joins the `['flow']` default for the sibling's reason, unchanged:
// `stack.datasets` is the one collection it resolves into and the per-write
// report snapshot carries it, so there is no missing-collection
// false-positive channel. The publish door is the only door a Studio or
// MCP/AI author has, and a window the renderer cannot apply should be
// refused there, not stored. A refusal widening on a NEW key only: MEASURED
// over the shipped report corpus (showcase 4, todo 5) before crossing — no
// report declares `dateRange`, so 0 findings by construction, and the door
// test's lit probes are refused (`runtime-gate.inert-type-writes.test.ts`).
{ name: 'validateReportDateRange', runtimeTypes: ['flow', 'report'], run: validateReportDateRange },
// [#14105] One level BELOW the two members above it. `validateChartBindings`
// and `validateWidgetBindings` resolve a presentation's binding against the
// dataset (#7529/#8902); this one resolves the DATASET's own references —
Expand Down
Loading
Loading