Skip to content

Commit 0dec20d

Browse files
committed
wip(spec): dataset duration measure schema
1 parent 5f5f0b4 commit 0dec20d

3 files changed

Lines changed: 195 additions & 5 deletions

File tree

‎packages/spec/src/data/analytics-column-reference.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,11 @@
7070
* `../ui/analytics-carrier-filter.ts` precedent).
7171
*/
7272

73+
/** One bare identifier — a column name, or one hop of a path. */
74+
const IDENTIFIER = '[A-Za-z_][A-Za-z0-9_]*';
75+
7376
/** A bare identifier, then zero or more `.identifier` hops — the column path. */
74-
const COLUMN_PATH = '[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*';
77+
const COLUMN_PATH = `${IDENTIFIER}(?:\\.${IDENTIFIER})*`;
7578

7679
/**
7780
* A column of the object, a relationship path ending in one, or the row
@@ -88,6 +91,15 @@ export const ANALYTICS_COLUMN_REFERENCE = new RegExp(`^(?:\\*|${COLUMN_PATH})$`)
8891
*/
8992
export const ANALYTICS_COLUMN_PATH = new RegExp(`^${COLUMN_PATH}$`);
9093

94+
/**
95+
* [#25778] {@link ANALYTICS_COLUMN_PATH} without the relationship hops — one
96+
* column of the dataset's OWN object, by its bare name: the slot of a duration
97+
* measure's `from` / `to` (`DatasetMeasureDurationSchema`,
98+
* `../ui/dataset.zod.ts`), which reads two columns of the same row. Built from
99+
* the same identifier as {@link COLUMN_PATH}, never a second spelling of it.
100+
*/
101+
export const ANALYTICS_BASE_COLUMN = new RegExp(`^${IDENTIFIER}$`);
102+
91103
/** The row wildcard — what a `count` aggregates (`COUNT(*)`). */
92104
const ROW_WILDCARD = '*';
93105

‎packages/spec/src/ui/dataset.form.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export const datasetForm = defineForm({
9191
{ field: 'format', label: 'Format' },
9292
{ field: 'currency', label: 'Currency' },
9393
{ field: 'derived', label: 'Derived From' },
94+
{ field: 'duration', label: 'Duration' },
9495
],
9596
},
9697
],

‎packages/spec/src/ui/dataset.zod.ts‎

Lines changed: 181 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
1010
import { I18nLabelSchema } from './i18n.zod';
1111
import { AggregationFunction, DateGranularity } from '../data/query.zod';
1212
import {
13+
ANALYTICS_BASE_COLUMN,
1314
ANALYTICS_COLUMN_PATH,
1415
ANALYTICS_COLUMN_REFERENCE,
1516
rowWildcardOutsideCount,
@@ -81,8 +82,9 @@ const DATASET_HISTORY =
8182
const 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
*/
208213
export 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

519694
export type DatasetDimension = z.input<typeof DatasetDimensionSchema>;
520695
export type DatasetMeasure = z.input<typeof DatasetMeasureSchema>;
696+
export type DatasetMeasureDuration = z.input<typeof DatasetMeasureDurationSchema>;
697+
export type DatasetDurationUnitValue = z.input<typeof DatasetDurationUnit>;
521698
export type DerivedMeasureOpValue = z.input<typeof DerivedMeasureOp>;
522699
export type Dataset = z.input<typeof DatasetSchema>;
523700

0 commit comments

Comments
 (0)