@@ -10,6 +10,7 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
1010import { I18nLabelSchema } from './i18n.zod' ;
1111import { AggregationFunction , DateGranularity } from '../data/query.zod' ;
1212import {
13+ ANALYTICS_BASE_COLUMN ,
1314 ANALYTICS_COLUMN_PATH ,
1415 ANALYTICS_COLUMN_REFERENCE ,
1516 rowWildcardOutsideCount ,
@@ -81,8 +82,9 @@ const DATASET_HISTORY =
8182const DATASET_NO_SQL =
8283 'the dataset layer takes no raw SQL — it is deliberately smaller than a query (ADR-0021). '
8384 + 'A dimension names a `field` (a base field or a `relationship[.relationship].field` path); '
84- + 'a measure names an `aggregate` + `field`, and the only computed form is '
85- + '`derived: { op, of: [...] }`, which combines OTHER measures in this dataset by name. '
85+ + 'a measure names an `aggregate` + `field`, and the two computed forms are '
86+ + '`derived: { op, of: [...] }`, which combines OTHER measures in this dataset by name, and '
87+ + '`duration: { from, to, unit }`, which aggregates the time between two datetime columns of each record. '
8688 + 'Joins are compiled from `Dataset.include` — you never write an ON clause.' ;
8789
8890/**
@@ -128,10 +130,13 @@ const DATASET_MEASURE_FIELD_NOT_COLUMN =
128130 + `a count may also omit \`field\`. ${ DATASET_FIELD_EXPRESSION_REFUSED } Name the column the measure `
129131 + 'aggregates. A derived value is declared in the ADR-0021 form, where the platform judges every field it '
130132 + 'reads: a conditional count or sum is a measure with its own structured `filter` '
131- + '(`{ name: \'done_count\', aggregate: \'count\', filter: { status: \'done\' } }`), and a ratio, sum, '
133+ + '(`{ name: \'done_count\', aggregate: \'count\', filter: { status: \'done\' } }`), a ratio, sum, '
132134 + 'difference or product of measures is `derived: { op, of: [...] }` over measures named in this dataset '
133135 + '(`{ name: \'done_rate\', derived: { op: \'ratio\', of: [\'done_count\', \'task_count\'] }, format: '
134- + '\'0.0%\' }` — a 0–1 fraction, which the `%` pattern displays as a percentage).' ;
136+ + '\'0.0%\' }` — a 0–1 fraction, which the `%` pattern displays as a percentage), and the time between two '
137+ + 'datetime columns of the same record is `duration: { from, to, unit }` '
138+ + '(`{ name: \'avg_cycle_days\', aggregate: \'avg\', duration: { from: \'submitted_at\', to: \'activated_at\', '
139+ + 'unit: \'days\' } }`).' ;
135140
136141/**
137142 * Dimension — a groupable axis (e.g. "region", "close_date by quarter").
@@ -207,6 +212,104 @@ export const DatasetDimensionSchema = lazySchema(() => strictObject({
207212 */
208213export const DerivedMeasureOp = z . enum ( [ 'ratio' , 'sum' , 'difference' , 'product' ] ) ;
209214
215+ /**
216+ * [#25778] The unit a duration measure's number is expressed in. Fixed-length
217+ * units only: a month or a year has no one length in seconds, so a duration in
218+ * either would depend on which calendar months the interval crosses.
219+ */
220+ export const DatasetDurationUnit = z . enum ( [ 'seconds' , 'minutes' , 'hours' , 'days' ] ) ;
221+
222+ /**
223+ * [#25778] Seconds per {@link DatasetDurationUnit} — the one conversion every
224+ * consumer divides by (the SQL compiler, the draft-preview evaluator, a
225+ * renderer suffixing the unit), so no two of them can disagree about a day.
226+ */
227+ export const DATASET_DURATION_UNIT_SECONDS : Readonly < Record < z . infer < typeof DatasetDurationUnit > , number > > =
228+ Object . freeze ( { seconds : 1 , minutes : 60 , hours : 3600 , days : 86400 } ) ;
229+
230+ /**
231+ * [#25778] The aggregates a duration measure takes. Each reads the per-record
232+ * duration as a number: `avg` and `sum` add them up, `min` and `max` pick one.
233+ * `count` and `count_distinct` are not here: a count of records with both
234+ * instants set is a `count` measure with a `filter`, not a duration.
235+ */
236+ export const DATASET_DURATION_AGGREGATES = [ 'avg' , 'min' , 'max' , 'sum' ] as const ;
237+
238+ const DATASET_DURATION_COLUMN_NOT_BASE =
239+ 'a duration reads two columns of the SAME record: name a `datetime` field of the dataset\'s own object '
240+ + '(`submitted_at`), by its bare name. A relationship path, a SQL expression, `\'*\'` or an empty string '
241+ + 'is refused: the dataset layer takes no raw SQL or expressions (ADR-0021).' ;
242+
243+ /**
244+ * [#25778] A duration measure's column pair: the time from `from` to `to` on
245+ * each record, in `unit`, which the measure's `aggregate` then reduces.
246+ *
247+ * Both columns are `datetime` fields of the dataset's own object — the time
248+ * between two instants of one record (cycle time, time in stage, time to
249+ * first response). The type is judged where the declarations are in hand: at
250+ * authoring (`measure-duration-field-type-refused`) and when the dataset is
251+ * compiled (`400 DATASET_INVALID`).
252+ *
253+ * A record whose `from` or `to` is null contributes nothing: it is excluded
254+ * from `avg`, `min`, `max` and `sum` alike, so `avg` is the mean over records
255+ * with both instants set. A record whose `to` is before its `from` contributes
256+ * a NEGATIVE duration, as written — the measure reports the data, it does not
257+ * repair it; filter such records out with the measure's `filter` if they are
258+ * errors.
259+ *
260+ * The result is a number of `unit`s (`1.5` with `unit: 'days'` is a day and a
261+ * half), the same on every SQL dialect the analytics runtime serves. No
262+ * expression language and no raw SQL: the pair is two column names and a unit
263+ * word, so every field it reads stays judgeable (ADR-0021).
264+ */
265+ export const DatasetMeasureDurationSchema = lazySchema ( ( ) => strictObject ( {
266+ surface : 'this duration-measure spec' ,
267+ history : DATASET_HISTORY ,
268+ aliases : {
269+ start : 'from' ,
270+ begin : 'from' ,
271+ since : 'from' ,
272+ startField : 'from' ,
273+ fromField : 'from' ,
274+ end : 'to' ,
275+ until : 'to' ,
276+ endField : 'to' ,
277+ toField : 'to' ,
278+ units : 'unit' ,
279+ interval : 'unit' ,
280+ } ,
281+ guidance : {
282+ sql : DATASET_NO_SQL ,
283+ expression : DATASET_NO_SQL ,
284+ formula : DATASET_NO_SQL ,
285+ field :
286+ 'a duration reads TWO columns — name them in `from` (the earlier instant) and `to` (the later one); '
287+ + 'the measure itself carries no `field`.' ,
288+ } ,
289+ } , {
290+ from : z . string ( )
291+ . regex ( ANALYTICS_BASE_COLUMN , { error : ( ) => DATASET_DURATION_COLUMN_NOT_BASE } )
292+ . describe ( 'The earlier instant: a datetime field of the dataset\'s own object, by its bare name.' )
293+ . meta ( { title : 'From' } ) ,
294+ to : z . string ( )
295+ . regex ( ANALYTICS_BASE_COLUMN , { error : ( ) => DATASET_DURATION_COLUMN_NOT_BASE } )
296+ . describe ( 'The later instant: a datetime field of the dataset\'s own object, by its bare name.' )
297+ . meta ( { title : 'To' } ) ,
298+ unit : DatasetDurationUnit
299+ . describe ( 'Unit of the result: the measure answers a number of these (1.5 with "days" is a day and a half).' )
300+ . meta ( { title : 'Unit' } ) ,
301+ } ) . superRefine ( ( duration , ctx ) => {
302+ if ( duration . from === duration . to ) {
303+ ctx . addIssue ( {
304+ code : 'custom' ,
305+ path : [ 'to' ] ,
306+ message :
307+ `\`from\` and \`to\` both name "${ duration . from } ", so every record's duration is zero. `
308+ + 'Name the two instants the duration spans — e.g. `from: \'submitted_at\'`, `to: \'activated_at\'`.' ,
309+ } ) ;
310+ }
311+ } ) ) ;
312+
210313/**
211314 * Measure — an aggregatable value (e.g. "revenue = sum(amount)"). Defined ONCE
212315 * here; every presentation references it by name.
@@ -243,6 +346,9 @@ export const DatasetMeasureSchema = lazySchema(() => strictObject({
243346 // Computed measures.
244347 calculated : 'derived' ,
245348 computed : 'derived' ,
349+ // [#25778] The column-pair measure.
350+ elapsed : 'duration' ,
351+ timeBetween : 'duration' ,
246352 } ,
247353 guidance : {
248354 sql : DATASET_NO_SQL ,
@@ -361,7 +467,72 @@ export const DatasetMeasureSchema = lazySchema(() => strictObject({
361467 /** Names of other measures in this dataset (2+ for ratio/difference). */
362468 of : z . array ( SnakeCaseIdentifierSchema ) . min ( 1 ) ,
363469 } ) . optional ( ) . meta ( { title : 'Derived From' } ) ,
470+ /**
471+ * [#25778] Duration measure — the time between two `datetime` columns of
472+ * each record, in `unit`, reduced by `aggregate` (`avg` / `min` / `max` /
473+ * `sum`): e.g. `{ aggregate: 'avg', duration: { from: 'submitted_at', to:
474+ * 'activated_at', unit: 'days' } }` is the average cycle time in days. See
475+ * {@link DatasetMeasureDurationSchema}. Excludes `field` and `derived`: the
476+ * two columns are the measure's source.
477+ */
478+ duration : DatasetMeasureDurationSchema . optional ( ) . describe (
479+ 'Time between two datetime columns of each record ({ from, to, unit }), reduced by `aggregate` '
480+ + '(avg / min / max / sum). Records with either instant null are excluded. Replaces `field`.' ,
481+ ) . meta ( { title : 'Duration' } ) ,
364482} ) . superRefine ( ( measure , ctx ) => {
483+ // [#25778] A duration measure's source is its column pair, and its value is
484+ // a number of `unit`s: so no `field` beside it (two sources), no `derived`
485+ // (two computed forms), only the aggregates that read a number, and no
486+ // `currency` (a duration is no amount of money).
487+ if ( measure . duration ) {
488+ if ( measure . field !== undefined ) {
489+ ctx . addIssue ( {
490+ code : 'custom' ,
491+ path : [ 'field' ] ,
492+ message :
493+ 'a duration measure reads the two columns named in `duration.from` / `duration.to` — it takes no '
494+ + '`field`. Delete `field`, or delete `duration` to aggregate one column.' ,
495+ } ) ;
496+ }
497+ if ( measure . derived !== undefined ) {
498+ ctx . addIssue ( {
499+ code : 'custom' ,
500+ path : [ 'derived' ] ,
501+ message :
502+ 'a measure is either `derived` (combines other measures by name) or a `duration` (two columns of '
503+ + 'each record), never both. Declare them as two measures; a derived measure may name the duration one in `of`.' ,
504+ } ) ;
505+ }
506+ if ( measure . aggregate === undefined ) {
507+ ctx . addIssue ( {
508+ code : 'custom' ,
509+ path : [ 'aggregate' ] ,
510+ message :
511+ `a duration measure needs \`aggregate\` — one of ${ DATASET_DURATION_AGGREGATES . join ( ', ' ) } — to reduce `
512+ + 'the per-record durations to one number (e.g. `aggregate: \'avg\'` for the average cycle time).' ,
513+ } ) ;
514+ } else if ( ! ( DATASET_DURATION_AGGREGATES as readonly string [ ] ) . includes ( measure . aggregate ) ) {
515+ ctx . addIssue ( {
516+ code : 'custom' ,
517+ path : [ 'aggregate' ] ,
518+ message :
519+ `a duration measure takes \`aggregate\` ${ DATASET_DURATION_AGGREGATES . join ( ' / ' ) } , not `
520+ + `"${ measure . aggregate } ": the per-record duration is a number to average, add up or pick the least or `
521+ + 'greatest of. To count records, declare a `count` measure (with a `filter` for the records whose '
522+ + 'two instants are set).' ,
523+ } ) ;
524+ }
525+ if ( measure . currency !== undefined ) {
526+ ctx . addIssue ( {
527+ code : 'custom' ,
528+ path : [ 'currency' ] ,
529+ message :
530+ 'a duration measure answers a number of `duration.unit`s, not an amount of money — delete `currency`. '
531+ + 'Decimals and grouping go in `format` (e.g. "0.0").' ,
532+ } ) ;
533+ }
534+ }
535+
365536 // [#21409] `'*'` is the row wildcard a `count` aggregates (`COUNT(*)`), and
366537 // only a `count` consumes it: under any other aggregate — or none, as on a
367538 // `derived` measure — it names no column. Measured at
@@ -463,6 +634,10 @@ export const DatasetSchema = lazySchema(() => strictObject({
463634 }
464635 // Derived measures may only reference OTHER measures declared in this dataset.
465636 for ( const m of ds . measures ) {
637+ // [#25778] A duration measure's aggregate and column pair are judged on the
638+ // measure itself (`DatasetMeasureSchema`'s refinement); it carries no
639+ // `field` and names no other measure, so neither check below is its.
640+ if ( m . duration ) continue ;
466641 if ( ! m . derived ) {
467642 // A non-derived measure must declare an aggregate (a derived measure
468643 // omits it — it combines other measures by name instead).
@@ -518,6 +693,8 @@ export function defineDataset(dataset: Dataset): Dataset {
518693
519694export type DatasetDimension = z . input < typeof DatasetDimensionSchema > ;
520695export type DatasetMeasure = z . input < typeof DatasetMeasureSchema > ;
696+ export type DatasetMeasureDuration = z . input < typeof DatasetMeasureDurationSchema > ;
697+ export type DatasetDurationUnitValue = z . input < typeof DatasetDurationUnit > ;
521698export type DerivedMeasureOpValue = z . input < typeof DerivedMeasureOp > ;
522699export type Dataset = z . input < typeof DatasetSchema > ;
523700
0 commit comments