Skip to content

Commit 24e5933

Browse files
committed
fix(app-shell): the dataset inspector clears a duration measure when the base object changes
A duration measure has no `field`, so `measureNamesNoField` read it as a plain row count and kept it across a base-object change, though its `from` / `to` name two of the old object's fields. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AKPixnkg91qCkQCVFDJuqW
1 parent 7caa566 commit 24e5933

3 files changed

Lines changed: 18 additions & 4 deletions

File tree

‎.changeset/25778-dataset-duration-measure.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
'@objectstack/service-analytics': minor
44
'@objectstack/lint': minor
55
'@objectstack/platform-objects': minor
6+
'@object-ui/app-shell': minor
67
---
78

8-
feat(spec,analytics,lint,platform-objects): a dataset measure can aggregate the time between two datetime fields of each record
9+
feat(spec,analytics,lint,platform-objects,app-shell): a dataset measure can aggregate the time between two datetime fields of each record
910

1011
Clause-②: yes (widening)
1112
Metadata-protocol: added
@@ -14,5 +15,6 @@ Metadata-protocol: added
1415
- **`@objectstack/service-analytics`**: the dataset compile refuses a duration column declared anything but `datetime` (`400 DATASET_INVALID`, naming the column and its type). The native-SQL strategy computes the duration as one statement on SQLite, PostgreSQL and MySQL, in double precision on each; SQLite and PostgreSQL are tested live to answer the same number: a record missing either instant is left out, a `to` before its `from` counts as negative, and over a group with no such record `sum` answers `0` and the others `null`. Measure filters, derived measures, ordering and the draft preview serve it. A query the native strategy does not serve (a time dimension bucketed by `granularity`, a field-to-field filter, an object served through engine middleware or an external datasource, a non-SQL datasource) is refused with `400 INVALID_FIELD` naming the measure. Its two columns pass the field-level read gate like any measure's field. The temporal half of the aggregate × field-type refusal now names the duration measure instead of a stored "days open" number.
1516
- **`@objectstack/lint`**: `os validate` refuses a duration column not declared `datetime` with the new gating rule `measure-duration-field-type-refused` (at `datasets[N].measures[M].duration.from` / `.to`), with an `os explain` entry. `dataset-field-unknown` now also reports a duration column the object does not declare.
1617
- **`@objectstack/platform-objects`**: the dataset form's new **Duration** row is translated in every shipped locale.
18+
- **`@object-ui/app-shell`**: changing a dataset's base object in the console's dataset inspector now clears a duration measure along with the other measures that name the old object's fields, instead of keeping it as if it were a plain row count.
1719

1820
What to do: nothing. A dataset without `duration` parses, compiles and answers exactly as before.

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.firstPickKeepsCount-12848.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ describe('objectChangePatch moves a dataset between objects (objectui#12848)', (
170170
{ name: 'amount_sum', aggregate: 'sum', field: 'amount' },
171171
{ name: 'won_count', aggregate: 'count', filter: { stage: 'won' } },
172172
{ name: 'won_rate', derived: { op: 'ratio', of: ['won_count', 'deal_count'] } },
173+
// objectstack#25778: no `field`, yet `from` / `to` name two of the old object's fields.
174+
{ name: 'avg_days_to_close', aggregate: 'avg', duration: { from: 'created_at', to: 'closed_at', unit: 'days' } },
173175
],
174176
};
175177

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ const CURRENCY_OPTIONS = [
129129

130130
type Dimension = { name?: string; label?: string; field?: string; type?: string; dateGranularity?: string };
131131
type DerivedSpec = { op?: string; of?: string[] };
132+
type DurationSpec = { from?: string; to?: string; unit?: string };
132133
type Measure = {
133134
name?: string;
134135
label?: string;
@@ -137,6 +138,8 @@ type Measure = {
137138
format?: string;
138139
currency?: string;
139140
derived?: DerivedSpec;
141+
/** Time between two `datetime` fields of a record (`DatasetMeasureSchema.duration`, objectstack#25778). */
142+
duration?: DurationSpec;
140143
filter?: FilterCondition;
141144
};
142145

@@ -361,11 +364,18 @@ function DatasetFilterField({ label, help, value, onCommit, fields, disabled, lo
361364
/**
362365
* Whether a measure names none of its object's fields: a plain row count — no
363366
* `field` (or the row wildcard `'*'`), no measure-scoped `filter` (its keys are
364-
* fields), and not `derived` (a derived measure combines other measures, which
365-
* may name fields, and one left pointing at a cleared measure is refused).
367+
* fields), not `derived` (a derived measure combines other measures, which
368+
* may name fields, and one left pointing at a cleared measure is refused), and
369+
* not a `duration` (its `from` / `to` are two fields, with no `field` beside
370+
* them — objectstack#25778).
366371
*/
367372
function measureNamesNoField(m: Measure): boolean {
368-
return (isBlankField(m.field) || m.field === '*') && m.filter === undefined && m.derived === undefined;
373+
return (
374+
(isBlankField(m.field) || m.field === '*') &&
375+
m.filter === undefined &&
376+
m.derived === undefined &&
377+
m.duration === undefined
378+
);
369379
}
370380

371381
/**

0 commit comments

Comments
 (0)