measurr 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dataset.ts CHANGED
@@ -9,15 +9,27 @@ import {
9
9
  directChildren,
10
10
  rootOf
11
11
  } from './expr.js'
12
+ import { DEFAULT_PERIOD, type PeriodInput } from './period.js'
12
13
 
13
14
  declare const ratioBrand: unique symbol
14
15
 
15
16
  /** A share or ratio between 0 and 1. Branded so nobody renders 0.42 as "0.42%". */
16
17
  export type Ratio = number & { readonly [ratioBrand]: 'zeroToOne' }
17
18
 
18
- type Described = {
19
+ type Described<Access extends string = string> = {
19
20
  label: string
20
21
  description?: string | undefined
22
+ /**
23
+ * Restricts the field to callers `authorizeField` allows for this access key (a string the
24
+ * app defines, such as `'staff'`). Unmarked fields are open to everyone who may query the
25
+ * dataset.
26
+ */
27
+ access?: Access | undefined
28
+ }
29
+
30
+ /** Keeps a field's access key literal, so `authorizeField` is typed with the keys in use. */
31
+ export type Marked<Access extends string> = {
32
+ readonly access: Access | undefined
21
33
  }
22
34
 
23
35
  // ---------------------------------------------------------------------------------------------
@@ -36,6 +48,7 @@ export type AggregateMeasure<
36
48
  readonly result: R
37
49
  readonly label: string
38
50
  readonly description: string | undefined
51
+ readonly access: string | undefined
39
52
  readonly node: Extract<ExprNode, { kind: 'aggregate' }>
40
53
  }
41
54
 
@@ -44,6 +57,7 @@ export type ShareMeasure = {
44
57
  readonly result: 'ratio'
45
58
  readonly label: string
46
59
  readonly description: string | undefined
60
+ readonly access: string | undefined
47
61
  readonly numerator: ExprNode
48
62
  readonly denominator: ExprNode | null
49
63
  }
@@ -56,6 +70,8 @@ export type RatioMeasure<
56
70
  readonly result: 'ratio'
57
71
  readonly label: string
58
72
  readonly description: string | undefined
73
+ /** A ratio also needs the access of the measures it divides. */
74
+ readonly access: string | undefined
59
75
  readonly numerator: A
60
76
  readonly denominator: B
61
77
  }
@@ -69,19 +85,22 @@ export type MeasureValue<M> = M extends { result: 'count' }
69
85
  ? number | null
70
86
  : Ratio | null
71
87
 
72
- type Filtered = Described & { where?: Expr<'boolean'> | undefined }
88
+ type Filtered<Access extends string> = Described<Access> & {
89
+ where?: Expr<'boolean'> | undefined
90
+ }
73
91
 
74
- function aggregate<R extends 'count' | 'number'>(
92
+ function aggregate<R extends 'count' | 'number', Access extends string>(
75
93
  result: R,
76
94
  fn: Extract<ExprNode, { kind: 'aggregate' }>['fn'],
77
95
  arg: Expr<SqlType> | null,
78
- options: Filtered
79
- ): AggregateMeasure<R> {
96
+ options: Filtered<Access>
97
+ ): AggregateMeasure<R> & Marked<NoInfer<Access>> {
80
98
  return {
81
99
  kind: 'aggregate',
82
100
  result,
83
101
  label: options.label,
84
102
  description: options.description,
103
+ access: options.access,
85
104
  node: {
86
105
  kind: 'aggregate',
87
106
  fn,
@@ -93,35 +112,46 @@ function aggregate<R extends 'count' | 'number'>(
93
112
 
94
113
  export const measure = {
95
114
  /** Number of rows, optionally only those matching `where`. */
96
- count(options: Filtered): AggregateMeasure<'count'> {
115
+ count<Access extends string = never>(
116
+ options: Filtered<Access>
117
+ ): AggregateMeasure<'count'> & Marked<NoInfer<Access>> {
97
118
  return aggregate('count', 'count', null, options)
98
119
  },
99
120
 
100
121
  /** Number of rows matching `condition`. */
101
- countWhere(
122
+ countWhere<Access extends string = never>(
102
123
  condition: Expr<'boolean'>,
103
- options: Described
104
- ): AggregateMeasure<'count'> {
124
+ options: Described<Access>
125
+ ): AggregateMeasure<'count'> & Marked<NoInfer<Access>> {
105
126
  return aggregate('count', 'count', null, { ...options, where: condition })
106
127
  },
107
128
 
108
129
  /** Number of distinct non-null values of `expr`. */
109
- countDistinct(
130
+ countDistinct<Access extends string = never>(
110
131
  expr: Expr<SqlType>,
111
- options: Filtered
112
- ): AggregateMeasure<'count'> {
132
+ options: Filtered<Access>
133
+ ): AggregateMeasure<'count'> & Marked<NoInfer<Access>> {
113
134
  return aggregate('count', 'countDistinct', expr, options)
114
135
  },
115
136
 
116
- sum(expr: Expr<'number'>, options: Filtered): AggregateMeasure<'number'> {
137
+ sum<Access extends string = never>(
138
+ expr: Expr<'number'>,
139
+ options: Filtered<Access>
140
+ ): AggregateMeasure<'number'> & Marked<NoInfer<Access>> {
117
141
  return aggregate('number', 'sum', expr, options)
118
142
  },
119
143
 
120
- avg(expr: Expr<'number'>, options: Filtered): AggregateMeasure<'number'> {
144
+ avg<Access extends string = never>(
145
+ expr: Expr<'number'>,
146
+ options: Filtered<Access>
147
+ ): AggregateMeasure<'number'> & Marked<NoInfer<Access>> {
121
148
  return aggregate('number', 'avg', expr, options)
122
149
  },
123
150
 
124
- median(expr: Expr<'number'>, options: Filtered): AggregateMeasure<'number'> {
151
+ median<Access extends string = never>(
152
+ expr: Expr<'number'>,
153
+ options: Filtered<Access>
154
+ ): AggregateMeasure<'number'> & Marked<NoInfer<Access>> {
125
155
  return aggregate('number', 'median', expr, options)
126
156
  },
127
157
 
@@ -129,17 +159,18 @@ export const measure = {
129
159
  * The share of rows matching `numerator` among rows matching `denominator` (all rows when
130
160
  * omitted). A `Ratio` from 0 to 1.
131
161
  */
132
- share(
133
- options: Described & {
162
+ share<Access extends string = never>(
163
+ options: Described<Access> & {
134
164
  numerator: Expr<'boolean'>
135
165
  denominator?: Expr<'boolean'> | undefined
136
166
  }
137
- ): ShareMeasure {
167
+ ): ShareMeasure & Marked<NoInfer<Access>> {
138
168
  return {
139
169
  kind: 'share',
140
170
  result: 'ratio',
141
171
  label: options.label,
142
172
  description: options.description,
173
+ access: options.access,
143
174
  numerator: options.numerator.node,
144
175
  denominator: options.denominator?.node ?? null
145
176
  }
@@ -149,16 +180,21 @@ export const measure = {
149
180
  * One measure of this dataset over another, computed after aggregation. Both keys must be
150
181
  * measures of the same dataset; `defineDataset` checks them.
151
182
  */
152
- ratio<const A extends string, const B extends string>(
183
+ ratio<
184
+ const A extends string,
185
+ const B extends string,
186
+ Access extends string = never
187
+ >(
153
188
  numerator: A,
154
189
  denominator: B,
155
- options: Described
156
- ): RatioMeasure<A, B> {
190
+ options: Described<Access>
191
+ ): RatioMeasure<A, B> & Marked<NoInfer<Access>> {
157
192
  return {
158
193
  kind: 'ratio',
159
194
  result: 'ratio',
160
195
  label: options.label,
161
196
  description: options.description,
197
+ access: options.access,
162
198
  numerator,
163
199
  denominator
164
200
  }
@@ -180,8 +216,9 @@ export type DimensionCategory = 'closed' | 'open' | 'numeric' | 'time'
180
216
 
181
217
  type DimensionOptions<
182
218
  G extends boolean = boolean,
183
- F extends boolean = boolean
184
- > = Described & {
219
+ F extends boolean = boolean,
220
+ Access extends string = string
221
+ > = Described<Access> & {
185
222
  /** Only offered for grouping. */
186
223
  groupOnly?: G | undefined
187
224
  /** Only offered for filtering. */
@@ -189,19 +226,25 @@ type DimensionOptions<
189
226
  }
190
227
 
191
228
  /** Time dimensions name themselves after their unit unless given a label. */
192
- type TimeDimensionOptions<G extends boolean, F extends boolean, Key> = Omit<
193
- DimensionOptions<G, F>,
194
- 'label'
195
- > & {
229
+ type TimeDimensionOptions<
230
+ G extends boolean,
231
+ F extends boolean,
232
+ Key,
233
+ Access extends string
234
+ > = Omit<DimensionOptions<G, F, Access>, 'label'> & {
196
235
  label?: string | undefined
197
236
  labelFor?: ((key: Key) => string) | undefined
198
237
  }
199
238
 
200
239
  /**
201
240
  * `groupOnly: true` makes `filterable` the literal `false` (and `filterOnly`, `groupable`), so
202
- * queries cannot filter or group by what the dimension does not offer.
241
+ * queries cannot filter or group by what the dimension does not offer. `access` stays literal.
203
242
  */
204
- export type DimensionAccess<G extends boolean, F extends boolean> = {
243
+ export type DimensionAccess<
244
+ G extends boolean,
245
+ F extends boolean,
246
+ Access extends string = never
247
+ > = Marked<Access> & {
205
248
  readonly groupable: [F] extends [true] ? false : boolean
206
249
  readonly filterable: [G] extends [true] ? false : boolean
207
250
  }
@@ -210,6 +253,7 @@ type DimensionBase<C extends DimensionCategory, Key> = {
210
253
  readonly category: C
211
254
  readonly label: string
212
255
  readonly description: string | undefined
256
+ readonly access: string | undefined
213
257
  readonly groupable: boolean
214
258
  readonly filterable: boolean
215
259
  /** Phantom: the key type in result rows. */
@@ -316,23 +360,32 @@ function base(options: DimensionOptions) {
316
360
  return {
317
361
  label: options.label,
318
362
  description: options.description,
363
+ access: options.access,
319
364
  groupable: options.filterOnly !== true,
320
365
  filterable: options.groupOnly !== true
321
366
  }
322
367
  }
323
368
 
324
- /** Narrows `groupable` and `filterable` (which `base` computed) to what the options say. */
369
+ /**
370
+ * Narrows `groupable`, `filterable` and `access` (which `base` copied) to what the options
371
+ * say.
372
+ */
325
373
  function withAccess<
326
374
  D extends AnyDimension,
327
375
  G extends boolean,
328
- F extends boolean
376
+ F extends boolean,
377
+ Access extends string
329
378
  >(
330
379
  _options:
331
- | { groupOnly?: G | undefined; filterOnly?: F | undefined }
380
+ | {
381
+ groupOnly?: G | undefined
382
+ filterOnly?: F | undefined
383
+ access?: Access | undefined
384
+ }
332
385
  | undefined,
333
386
  dimension: D
334
- ): D & DimensionAccess<G, F> {
335
- return dimension as D & DimensionAccess<G, F>
387
+ ): D & DimensionAccess<G, F, NoInfer<Access>> {
388
+ return dimension as D & DimensionAccess<G, F, Access>
336
389
  }
337
390
 
338
391
  export const dimension = {
@@ -345,13 +398,15 @@ export const dimension = {
345
398
  const E extends Record<string, string>,
346
399
  V extends string | null,
347
400
  G extends boolean = boolean,
348
- F extends boolean = boolean
401
+ F extends boolean = boolean,
402
+ Access extends string = never
349
403
  >(
350
404
  expr: Expr<'string', V> & CoversValues<V, E[keyof E]>,
351
405
  values: E,
352
- options: DimensionOptions<G, F> &
406
+ options: DimensionOptions<G, F, Access> &
353
407
  EmptyOption & { labels: Record<E[keyof E], string> }
354
- ): EnumDimension<E[keyof E], EmptyOf<V>> & DimensionAccess<G, F> {
408
+ ): EnumDimension<E[keyof E], EmptyOf<V>> &
409
+ DimensionAccess<G, F, NoInfer<Access>> {
355
410
  for (const value of Object.values(values)) {
356
411
  if (!(value in options.labels)) {
357
412
  throw new Error(`${options.label}: the value ${value} has no label`)
@@ -378,14 +433,15 @@ export const dimension = {
378
433
  V extends string | null,
379
434
  const K extends string,
380
435
  G extends boolean = boolean,
381
- F extends boolean = boolean
436
+ F extends boolean = boolean,
437
+ Access extends string = never
382
438
  >(
383
- options: DimensionOptions<G, F> &
439
+ options: DimensionOptions<G, F, Access> &
384
440
  EmptyOption & {
385
441
  expr: Expr<'string', V> & CoversValues<V, NoInfer<K>>
386
442
  values: Record<K, string>
387
443
  }
388
- ): EnumDimension<K, EmptyOf<V>> & DimensionAccess<G, F> {
444
+ ): EnumDimension<K, EmptyOf<V>> & DimensionAccess<G, F, NoInfer<Access>> {
389
445
  return withAccess(options, {
390
446
  ...base(options),
391
447
  category: 'closed',
@@ -404,14 +460,15 @@ export const dimension = {
404
460
  relation<
405
461
  Key extends string | number,
406
462
  G extends boolean = boolean,
407
- F extends boolean = boolean
463
+ F extends boolean = boolean,
464
+ Access extends string = never
408
465
  >(
409
- options: DimensionOptions<G, F> & {
466
+ options: DimensionOptions<G, F, Access> & {
410
467
  key: Expr<Key extends number ? 'number' : 'string', Key | null>
411
468
  name: Expr<'string'>
412
469
  empty?: string | undefined
413
470
  }
414
- ): RelationDimension<Key> & DimensionAccess<G, F> {
471
+ ): RelationDimension<Key> & DimensionAccess<G, F, NoInfer<Access>> {
415
472
  return withAccess(options, {
416
473
  ...base(options),
417
474
  category: 'open',
@@ -433,9 +490,10 @@ export const dimension = {
433
490
  manyToMany<
434
491
  Row = unknown,
435
492
  G extends boolean = boolean,
436
- F extends boolean = boolean
493
+ F extends boolean = boolean,
494
+ Access extends string = never
437
495
  >(
438
- options: DimensionOptions<G, F> & {
496
+ options: DimensionOptions<G, F, Access> & {
439
497
  through: {
440
498
  table: Table<Row>
441
499
  on: Expr<'boolean'>
@@ -446,7 +504,7 @@ export const dimension = {
446
504
  note?: string | undefined
447
505
  empty?: string | undefined
448
506
  }
449
- ): ManyToManyDimension & DimensionAccess<G, F> {
507
+ ): ManyToManyDimension & DimensionAccess<G, F, NoInfer<Access>> {
450
508
  return withAccess(options, {
451
509
  ...base(options),
452
510
  category: 'open',
@@ -461,12 +519,16 @@ export const dimension = {
461
519
  },
462
520
 
463
521
  /** A numeric column, grouped by its exact values and filtered with `between`. */
464
- number<G extends boolean = boolean, F extends boolean = boolean>(
522
+ number<
523
+ G extends boolean = boolean,
524
+ F extends boolean = boolean,
525
+ Access extends string = never
526
+ >(
465
527
  expr: Expr<'number'>,
466
- options: DimensionOptions<G, F> & {
528
+ options: DimensionOptions<G, F, Access> & {
467
529
  labelFor?: ((key: number) => string) | undefined
468
530
  }
469
- ): NumberDimension & DimensionAccess<G, F> {
531
+ ): NumberDimension & DimensionAccess<G, F, NoInfer<Access>> {
470
532
  return withAccess(options, {
471
533
  ...base(options),
472
534
  category: 'numeric',
@@ -480,12 +542,13 @@ export const dimension = {
480
542
  timeBucket<
481
543
  const T extends string,
482
544
  G extends boolean = boolean,
483
- F extends boolean = boolean
545
+ F extends boolean = boolean,
546
+ Access extends string = never
484
547
  >(
485
548
  time: T,
486
549
  unit: TimeBucketUnit,
487
- options?: TimeDimensionOptions<G, F, string>
488
- ): TimeBucketDimension<T> & DimensionAccess<G, F> {
550
+ options?: TimeDimensionOptions<G, F, string, Access>
551
+ ): TimeBucketDimension<T> & DimensionAccess<G, F, NoInfer<Access>> {
489
552
  return withAccess(options, {
490
553
  ...base({
491
554
  ...options,
@@ -503,12 +566,13 @@ export const dimension = {
503
566
  timePart<
504
567
  const T extends string,
505
568
  G extends boolean = boolean,
506
- F extends boolean = boolean
569
+ F extends boolean = boolean,
570
+ Access extends string = never
507
571
  >(
508
572
  time: T,
509
573
  part: TimePartName,
510
- options?: TimeDimensionOptions<G, F, number>
511
- ): TimePartDimension<T> & DimensionAccess<G, F> {
574
+ options?: TimeDimensionOptions<G, F, number, Access>
575
+ ): TimePartDimension<T> & DimensionAccess<G, F, NoInfer<Access>> {
512
576
  return withAccess(options, {
513
577
  ...base({
514
578
  ...options,
@@ -532,17 +596,21 @@ export type Dataset<
532
596
  K extends string = string,
533
597
  M extends Record<string, AnyMeasure> = Record<string, AnyMeasure>,
534
598
  Dm extends Record<string, AnyDimension> = Record<string, AnyDimension>,
535
- T extends string = string
599
+ T extends string = string,
600
+ P extends PeriodInput = PeriodInput
536
601
  > = {
537
602
  readonly key: K
538
603
  readonly label: string
539
604
  readonly description: string
540
605
  readonly from: TableRef
541
606
  readonly source: string | undefined
542
- readonly tenantColumn: ExprNode
607
+ /** Null for a dataset without tenants, which only `allTenants` may query. */
608
+ readonly tenantColumn: ExprNode | null
543
609
  readonly scope: ExprNode | null
544
610
  readonly time: Readonly<Record<T, TimeField>>
545
611
  readonly defaultTime: T
612
+ /** Kept as a type, so results know whether a query without a period covers all time. */
613
+ readonly defaultPeriod: P
546
614
  readonly measures: M
547
615
  readonly dimensions: Dm
548
616
  }
@@ -571,7 +639,8 @@ export type DatasetDefinition<
571
639
  K extends string,
572
640
  M extends Record<string, AnyMeasure>,
573
641
  Dm extends Record<string, AnyDimension>,
574
- T extends string
642
+ T extends string,
643
+ P extends PeriodInput
575
644
  > = {
576
645
  key: K
577
646
  label: string
@@ -580,13 +649,22 @@ export type DatasetDefinition<
580
649
  from: { readonly ref: TableRef }
581
650
  /** Which source (database) the dataset runs on; the default when there is one source. */
582
651
  source?: string | undefined
583
- /** The column every query is filtered by; the value comes from `tenant(ctx)`. */
584
- tenantColumn: Expr<SqlType>
652
+ /**
653
+ * The column every query is filtered by; the value comes from `tenant(ctx)`. `null` for a
654
+ * dataset whose rows belong to no tenant (staff work, platform-wide records): only a caller
655
+ * whose `tenant(ctx)` is `allTenants` may query it, and everyone else is refused.
656
+ */
657
+ tenantColumn: Expr<SqlType> | null
585
658
  /** Always applied, for example excluding test rows. */
586
659
  scope?: Expr<'boolean'> | undefined
587
660
  time: Record<T, { column: Expr<'timestamp'>; label: string }>
588
661
  /** The time field periods apply to when a query names none; the first one by default. */
589
662
  defaultTime?: NoInfer<T> | undefined
663
+ /**
664
+ * The period of a query that names none; the last 30 days by default. A dataset of current
665
+ * state (units, organizations) sets `{ all: true }`.
666
+ */
667
+ defaultPeriod?: P | undefined
590
668
  measures: M & CheckMeasures<M>
591
669
  dimensions: Dm & CheckDimensions<Dm, NoInfer<T>>
592
670
  }
@@ -599,8 +677,9 @@ export function defineDataset<
599
677
  const K extends string,
600
678
  const M extends Record<string, AnyMeasure>,
601
679
  const Dm extends Record<string, AnyDimension>,
602
- const T extends string
603
- >(definition: DatasetDefinition<K, M, Dm, T>): Dataset<K, M, Dm, T> {
680
+ const T extends string,
681
+ const P extends PeriodInput = typeof DEFAULT_PERIOD
682
+ >(definition: DatasetDefinition<K, M, Dm, T, P>): Dataset<K, M, Dm, T, P> {
604
683
  const timeEntries = Object.entries<{
605
684
  column: Expr<'timestamp'>
606
685
  label: string
@@ -612,6 +691,13 @@ export function defineDataset<
612
691
  )
613
692
  }
614
693
 
694
+ // A JavaScript caller that leaves it out must not get a dataset without tenants.
695
+ if (definition.tenantColumn === undefined) {
696
+ throw new Error(
697
+ `Dataset ${definition.key}: declare tenantColumn, or null for a dataset without tenants`
698
+ )
699
+ }
700
+
615
701
  const time = Object.fromEntries(
616
702
  timeEntries.map(([key, field]) => [
617
703
  key,
@@ -619,16 +705,18 @@ export function defineDataset<
619
705
  ])
620
706
  ) as Record<T, TimeField>
621
707
 
622
- const dataset: Dataset<K, M, Dm, T> = {
708
+ const dataset: Dataset<K, M, Dm, T, P> = {
623
709
  key: definition.key,
624
710
  label: definition.label,
625
711
  description: definition.description,
626
712
  from: definition.from.ref,
627
713
  source: definition.source,
628
- tenantColumn: definition.tenantColumn.node,
714
+ tenantColumn: definition.tenantColumn?.node ?? null,
629
715
  scope: definition.scope?.node ?? null,
630
716
  time,
631
717
  defaultTime: definition.defaultTime ?? (firstTime[0] as T),
718
+ // P is DEFAULT_PERIOD's type when the definition names no period.
719
+ defaultPeriod: definition.defaultPeriod ?? (DEFAULT_PERIOD as P),
632
720
  measures: definition.measures,
633
721
  dimensions: definition.dimensions
634
722
  }
@@ -662,7 +750,7 @@ function validateDataset<DS extends AnyDataset>(dataset: DS): DS {
662
750
  })
663
751
  }
664
752
 
665
- check(dataset.tenantColumn, 'tenantColumn')
753
+ if (dataset.tenantColumn) check(dataset.tenantColumn, 'tenantColumn')
666
754
  if (dataset.scope) check(dataset.scope, 'scope')
667
755
  for (const [key, field] of Object.entries<TimeField>(dataset.time)) {
668
756
  check(field.column, `time field ${key}`)
package/src/guidance.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export const analyticsToolGuidance = `- Answer every "how many", "how much", "per week" or "split by" question with the analytics tool. Never count by hand from lists or pages.
6
6
  - Pick the dataset whose description matches the question, the fewest measures that answer it and at most two groupings.
7
- - Say which period and filters the numbers cover, using the dates in the result ("1-30 September").
7
+ - Say which period and filters the numbers cover, using the dates in the result ("1-30 September", or "all time").
8
8
  - Compare with the previous period (compareToPrevious) when the change is the point, such as "is it going up".
9
9
  - When the result has notes (overlapping rows, such as a record counted once per tag), say so in a few words.
10
10
  - Ask one short question only when the period is genuinely ambiguous ("this season") and someone can answer; otherwise take the natural reading and state it.`
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export {
2
+ type AccessKey,
2
3
  type Analytics,
3
4
  AnalyticsError,
4
5
  type AnalyticsErrorCode,
@@ -11,6 +12,7 @@ export {
11
12
  type AnalyticsSource,
12
13
  createAnalytics,
13
14
  type DatasetCatalogEntry,
15
+ type FieldKey,
14
16
  isAnalyticsError
15
17
  } from './analytics.js'
16
18
  export {
@@ -65,11 +67,12 @@ export {
65
67
  } from './expr.js'
66
68
  export { analyticsToolGuidance } from './guidance.js'
67
69
  export type { PeriodInput, PeriodPreset } from './period.js'
68
- export { allTenants, type Tenant } from './plan.js'
70
+ export { allTenants, type Tenant, type TenantId } from './plan.js'
69
71
  export {
70
72
  AnalyticsResultSchema,
71
73
  type DatasetFilter,
72
74
  type DatasetQuery,
73
75
  type QueryResult,
74
- type ResultOf
76
+ type ResultOf,
77
+ type ResultPeriod
75
78
  } from './query.js'
package/src/period.ts CHANGED
@@ -13,11 +13,15 @@ export type PeriodPreset =
13
13
  | 'lastMonth'
14
14
  | 'thisYear'
15
15
 
16
- export type PeriodInput =
16
+ /** A range of days: what `resolvePeriod` turns into instants. */
17
+ export type PeriodRange =
17
18
  | { last: { days: number } }
18
19
  | { from: string; to: string }
19
20
  | { preset: PeriodPreset }
20
21
 
22
+ /** A range of days, or `{ all: true }` for no time filter. */
23
+ export type PeriodInput = PeriodRange | { all: true }
24
+
21
25
  export type ResolvedPeriod = {
22
26
  /** First local day, `YYYY-MM-DD`. */
23
27
  from: string
@@ -32,7 +36,9 @@ export type ResolvedPeriod = {
32
36
  }
33
37
 
34
38
  export const MAX_PERIOD_DAYS = 731
35
- export const DEFAULT_PERIOD: PeriodInput = { last: { days: 30 } }
39
+ export const DEFAULT_PERIOD = {
40
+ last: { days: 30 }
41
+ } as const satisfies PeriodInput
36
42
 
37
43
  const DAY_MS = 86_400_000
38
44
 
@@ -140,7 +146,7 @@ export class PeriodError extends Error {
140
146
 
141
147
  /** Resolves a period input to local days and instants in `timezone`. */
142
148
  export function resolvePeriod(
143
- input: PeriodInput,
149
+ input: PeriodRange,
144
150
  timezone: string,
145
151
  now: Date
146
152
  ): ResolvedPeriod {
@@ -154,7 +160,7 @@ export function resolvePeriod(
154
160
  return periodFromDays(from, to, timezone)
155
161
  }
156
162
 
157
- function periodDays(input: PeriodInput, today: string): [string, string] {
163
+ function periodDays(input: PeriodRange, today: string): [string, string] {
158
164
  if ('last' in input) return [addDays(today, -(input.last.days - 1)), today]
159
165
  if ('from' in input) return [input.from, input.to]
160
166