measurr 0.4.0 → 0.6.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/README.md CHANGED
@@ -176,9 +176,184 @@ ${analyticsToolGuidance}`
176
176
  ```
177
177
 
178
178
  `onQuery(event, ctx)` hears every `query` once it succeeds or fails:
179
- `{ dataset, durationMs, rows, outcome: 'success' | 'error', error? }`. `dataset` is null when
180
- the input named no known dataset, so free text from a model never becomes a metric label.
181
- What the hook throws or rejects with is swallowed; it cannot fail a query.
179
+ `{ dataset, durationMs, rows, outcome: 'success' | 'error', cache?, error? }`. `dataset` is null
180
+ when the input named no known dataset, so free text from a model never becomes a metric label.
181
+ `cache` is `'hit'` or `'miss'` when a cache is configured. What the hook throws or rejects with is
182
+ swallowed; it cannot fail a query.
183
+
184
+ Each measure in `listDatasets` has a `valueType`: `count` (a whole number, never null),
185
+ `ratio` (0-1, from `share` and `ratio`) or `number` (sums, averages, medians), so a UI knows
186
+ to format a share as a percentage.
187
+
188
+ ## Caching
189
+
190
+ `cache: { get, set }` keeps finished results in a store of your choice, such as Redis. A query
191
+ is parsed and authorized first, so a cached result never reaches a caller who may not run the
192
+ query. The key is `measurr:<dataset>:<sha256>`, hashed from the query with its defaults, the
193
+ tenant scope, the timezone and the resolved days (so `last 30 days` moves on at local midnight).
194
+ Results are plain JSON.
195
+
196
+ ```ts
197
+ const TTL_SECONDS: Record<AnalyticsDatasetKey, number> = { orders: 300, units: 60 }
198
+
199
+ createAnalytics({
200
+ // ...
201
+ cache: {
202
+ // A miss bypasses the cache; the fresh result is still stored.
203
+ get: async (key, { ctx }) =>
204
+ ctx.refresh ? null : JSON.parse((await redis.get(`${BUILD_ID}:${key}`)) ?? 'null'),
205
+ set: (key, result, { dataset }) =>
206
+ redis.setex(`${BUILD_ID}:${key}`, TTL_SECONDS[dataset], JSON.stringify(result))
207
+ }
208
+ })
209
+ ```
210
+
211
+ A stored value that is not a result of its dataset counts as a miss, and a cache that throws or
212
+ rejects is a miss or a skipped write, never a failed query. A changed measure keeps its key, so
213
+ prefix keys with a build id (as above) if old numbers must not outlive a deploy. A dataset that
214
+ uses `minutesAgo` is as stale as the time to live.
215
+
216
+ ## Chart shapes
217
+
218
+ `measurr/shapes` turns a result into what a chart or a KPI card draws. The functions are pure
219
+ and add no numbers the result does not hold; with `compareToPrevious` they carry the previous
220
+ period too.
221
+
222
+ ```ts
223
+ import { toDistribution, toKpi, toPivot, toTimeseries, toLeaderboard } from 'measurr/shapes'
224
+
225
+ toKpi(result, 'orders')
226
+ // { kind: 'kpi', value: 12, previous: 8, change: 0.5 }
227
+
228
+ toTimeseries(result, { bucket: 'day', unit: 'day', measure: 'orders', series: 'status' })
229
+ // { kind: 'timeseries', unit, series: [{ key, label, points: [{ bucket: '2026-10-01', value }], previous? }] }
230
+
231
+ toDistribution(result, { by: 'status', measure: 'orders' }) // segments with share of the total
232
+ toPivot(result, { rows: 'weekday', columns: 'hour', measure: 'orders' }) // values[row][column]
233
+ toLeaderboard(result, { by: 'region', measure: 'revenue' }) // ranked rows with every measure
234
+ ```
235
+
236
+ - `toTimeseries` gives every bucket of the period a point; buckets without rows get `fill` (0 by
237
+ default, pass `null` for averages). An all-time result has only the buckets it found. The
238
+ previous period's points are on its own buckets; compare them by position.
239
+ - A grouped query returns at most `limit` rows (20 by default, 100 at most), and the shape keeps
240
+ `truncated`. Set `limit: 100` for a series; 30 days split into three series fit, 90 days into
241
+ eleven do not.
242
+ - `toDistribution`'s `total` is the measure over every row, so many-to-many shares can add up to
243
+ more than 1. `toPivot` sorts numeric keys (weekdays, hours).
244
+
245
+ ### Presentation
246
+
247
+ `presentKpi`, `presentTimeseries`, `presentDistribution` and `presentLeaderboard` turn a result
248
+ into the wire outputs a dashboard draws (`KpiOutput`, `TimeSeriesOutput`, `DistributionOutput`,
249
+ `LeaderboardOutput`; `TableOutput` and `PivotOutput` for outputs you build yourself). A missing
250
+ value is 0, keys are strings, and the caller picks labels, units and chart types:
251
+
252
+ ```ts
253
+ presentKpi(result, 'paidShare', { label: 'Paid', percent: true })
254
+ // { kind: 'kpi', value: 25, unit: 'PERCENTAGE', label: 'Paid' }
255
+
256
+ presentTimeseries(result, {
257
+ bucket: 'day', measure: 'orders', series: 'status',
258
+ keys: { PAID: 'paid' }, // wire keys, by String(key); 'null' names the empty group
259
+ labels: { paid: 'Settled' }, // labels by wire key
260
+ chartType: 'line'
261
+ })
262
+ // points are { ts: Date, value }: ts is the bucket's local start date as UTC midnight
263
+
264
+ presentDistribution(result, { by: 'tag', measure: 'orders', chartType: 'donut', order: ['t2'] })
265
+ // total is what the segments add up to, percent is 0-100; keys order omits go last
266
+
267
+ presentLeaderboard(result, { by: 'region', measure: 'orders', meta: (values) => ({ revenue: values.revenue }) })
268
+ ```
269
+
270
+ `isKpi`, `isTimeSeries`, `isDistribution`, `isTable`, `isLeaderboard` and `isPivot` narrow a
271
+ `MetricOutput`. The module is pure, so a browser bundle can import the types and guards.
272
+
273
+ ## Dashboard metrics
274
+
275
+ `measurr/metrics` names the numbers a dashboard shows: one query and the output it is drawn
276
+ in. The registry runs a batch for a caller and streams one chunk per metric.
277
+
278
+ ```ts
279
+ import { createMetricRegistry, metricDefiner } from 'measurr/metrics'
280
+ import { presentKpi, presentTimeseries } from 'measurr/shapes'
281
+
282
+ // Types only, so metric modules need not import the analytics instance.
283
+ export const defineMetric = metricDefiner<(typeof datasets)[number], Role>()
284
+
285
+ const orderTrend = defineMetric({
286
+ key: 'orders.trend',
287
+ kind: 'timeseries',
288
+ description: 'Orders over time',
289
+ allowedRoles: ['owner', 'member'],
290
+ supportsTimeRange: true, // false for a snapshot: all time, no previous period
291
+ filterSchema: z.object({ status: z.enum(['OPEN', 'PAID']).optional() }),
292
+ query: ({ bucket, filters }) => ({
293
+ dataset: 'orders',
294
+ measures: ['orders'],
295
+ groupBy: [bucket],
296
+ filters: filters.status ? [{ dimension: 'status', op: 'in', values: [filters.status] }] : [],
297
+ limit: 100
298
+ }),
299
+ shape: (result, { bucket }) =>
300
+ presentTimeseries(result, { bucket, measure: 'orders', chartType: 'bar' })
301
+ })
302
+
303
+ export const metrics = createMetricRegistry({
304
+ analytics,
305
+ metrics: [orderTrend, orderCount] as const,
306
+ canRun: (metric, ctx) => ctx.isSuperAdmin || (metric.allowedRoles ?? []).includes(ctx.role),
307
+ onShapeError: (error, metric) => logger.error('metric.shape_failed', { key: metric.key, error })
308
+ })
309
+
310
+ const input = metrics.requestSchema.extend({ refresh: z.boolean().default(false) }).parse(body)
311
+ for await (const chunk of metrics.run(input, ctx)) send(chunk)
312
+ // { key, requestKey, result: { current, previous?, supportsTimeRange }, done }
313
+ // or { key, requestKey, error, done }
314
+ ```
315
+
316
+ - `query` returns the query without `period` or `compareToPrevious`. The registry counts the
317
+ local days `from` and `to` (instants) fall on in `analytics.timezone(ctx)`, all time without
318
+ them, and always all time for a snapshot. `compareToPrevious` adds the previous period, whose
319
+ series points are put on the current buckets so a chart overlays them.
320
+ - `granularity` (hour to year, inferred from the range when omitted: day up to 31 days, week up
321
+ to 90, else month) picks `run.bucket`: `day`, `week` or `month`. Name time-bucket dimensions
322
+ after them so `groupBy: [bucket]` works.
323
+ - A metric that `canRun` refuses (`'Access denied'`), whose filters do not parse with its
324
+ `filterSchema` (`'Invalid filters: ...'`), whose query fails (the engine's message) or whose
325
+ `shape` throws (`'The metric could not be drawn'`) fails alone. `done` marks the last chunk.
326
+ - `metricDefiner<Datasets, Role, Extra>()` takes the app's own definition fields as `Extra`
327
+ (catalog metadata, UI hints for filters); the registry carries them to `canRun` and
328
+ `available` without reading them.
329
+ - Keys and outputs stay literal: a chunk narrows to its metric by `key`, and
330
+ `MetricOutputOf<typeof metrics, 'orders.trend'>`, `MetricFiltersOf`, `MetricKeysOfKind` and
331
+ `registry.get(key)` are typed per metric. `registry.available(ctx)` lists what `canRun`
332
+ allows, for a catalog.
333
+
334
+ ## Serving queries
335
+
336
+ `measurr/handler` serves queries from any server framework:
337
+
338
+ - `queryBatch(analytics, { total: q1, byStatus: q2 }, ctx)` runs named queries in parallel and
339
+ resolves to `{ results, errors }`, typed per name. One failing query does not fail the
340
+ others; an error other than `AnalyticsError` is reported as `{ code: 'internal' }` without
341
+ its message.
342
+ - `streamBatch(analytics, queries, ctx)` yields `{ key, result }` or `{ key, error }` as each
343
+ query finishes, for a streaming endpoint.
344
+ - `toHttpError(error)` maps an `AnalyticsError` to `{ status, code, message, data? }`:
345
+ `forbidden` is 403 `FORBIDDEN`, `missing_tenant` 401 `UNAUTHORIZED`, the rest 400
346
+ `BAD_REQUEST` with the code and details. The codes are the names oRPC and tRPC use:
347
+
348
+ ```ts
349
+ try {
350
+ return await analytics.query(input, ctx)
351
+ } catch (error) {
352
+ if (!(error instanceof AnalyticsError)) throw error
353
+ const { code, message, data } = toHttpError(error)
354
+ throw new ORPCError(code, { message, data })
355
+ }
356
+ ```
182
357
 
183
358
  ## Tenants and platform admins
184
359
 
@@ -420,16 +595,20 @@ await assertDatasetContract({ dataset: maintenance, source, tenant: allTenants }
420
595
 
421
596
  ## Stable API
422
597
 
423
- Kept stable for publishing: `createAnalytics` and its options (with `authorize` and
424
- `authorizeField`), `allTenants`, `analytics.query`, `explain`, `querySchema`, `resultSchema`,
425
- `listDatasets`; `AnalyticsResultSchema`, `analyticsToolGuidance`, `AnalyticsQueryEvent`;
426
- `defineDataset` (with `defaultPeriod` and `tenantColumn: null`), `measure.*` and `dimension.*`
427
- (with `access`), `table` / `Table`, the expression builders; the `Dialect`,
428
- `SqlRenderer` and `AnalyticsSource` contracts; `postgresDialect`, `checkDatasetContract` /
429
- `assertDatasetContract`; and the types `AnalyticsQuery`, `AnalyticsResult`, `ResultPeriod`,
430
- `PeriodInput`, `Tenant`, `TenantId`, `AccessKey`, `FieldKey`, `Ratio`, `AnalyticsError` codes
431
- and their details (`isAnalyticsError`). `ExprNode` and `SelectStatement` are exported for
432
- dialect authors and may grow new node kinds.
598
+ Kept stable for publishing: `createAnalytics` and its options (with `authorize`,
599
+ `authorizeField` and `cache`), `allTenants`, `analytics.query`, `explain`, `querySchema`,
600
+ `resultSchema`, `listDatasets`; `AnalyticsResultSchema`, `analyticsToolGuidance`,
601
+ `AnalyticsQueryEvent`; `defineDataset` (with `defaultPeriod` and `tenantColumn: null`),
602
+ `measure.*` and `dimension.*` (with `access`), `table` / `Table`, the expression builders; the
603
+ `Dialect`, `SqlRenderer` and `AnalyticsSource` contracts; `postgresDialect`,
604
+ `checkDatasetContract` / `assertDatasetContract`; `measurr/shapes` (`toKpi`, `toTimeseries`,
605
+ `toDistribution`, `toPivot`, `toLeaderboard`) and `measurr/handler` (`queryBatch`,
606
+ `streamBatch`, `toHttpError`); and the types `AnalyticsQuery`, `AnalyticsResult`,
607
+ `ResultPeriod`, `PeriodInput`, `Tenant`, `TenantId`, `AccessKey`, `AnalyticsCache`, `FieldKey`,
608
+ `Ratio`, `AnalyticsError` codes and their details (`isAnalyticsError`). `ExprNode` and
609
+ `SelectStatement` are exported for dialect authors and may grow new node kinds. The
610
+ presenters and wire outputs in `measurr/shapes`, `measurr/metrics` and `analytics.timezone`
611
+ are new and may still change before they join this list.
433
612
 
434
613
  ## Install
435
614
 
@@ -50,6 +50,8 @@ export type AnalyticsQueryEvent = {
50
50
  /** Rows returned; 0 on error. */
51
51
  rows: number;
52
52
  outcome: 'success' | 'error';
53
+ /** Whether `cache` answered the query; absent without a cache or when it never got that far. */
54
+ cache?: 'hit' | 'miss';
53
55
  /** What the query threw, on error. */
54
56
  error?: unknown;
55
57
  };
@@ -90,6 +92,33 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
90
92
  * throws or rejects with is swallowed, so it can never fail a query.
91
93
  */
92
94
  onQuery?: ((event: AnalyticsQueryEvent, ctx: Ctx) => unknown) | undefined;
95
+ /**
96
+ * Where finished results are kept and looked up. What it throws or rejects with is
97
+ * swallowed: a failing cache is a miss, never a failed query.
98
+ */
99
+ cache?: AnalyticsCache<Ctx, DS[number]['key']> | undefined;
100
+ };
101
+ /**
102
+ * A store for finished results, such as Redis. Keys name the dataset and hash everything that
103
+ * decides the result: the query with its defaults, the tenant scope, the timezone and the
104
+ * resolved days. A query is parsed and authorized before the cache is asked, so a cached result
105
+ * never reaches a caller who may not run the query. Results are plain JSON.
106
+ */
107
+ export type AnalyticsCache<Ctx, Key extends string = string> = {
108
+ /**
109
+ * The stored result, or null/undefined for a miss. Return a miss to bypass the cache for a
110
+ * caller (a refresh button); its fresh result is still stored. A stored value that is not a
111
+ * result of this dataset counts as a miss, so a changed definition does not break queries.
112
+ */
113
+ get(key: string, entry: {
114
+ dataset: Key;
115
+ ctx: Ctx;
116
+ }): unknown;
117
+ /** Stores a result; pick the time to live per `dataset`. */
118
+ set(key: string, result: QueryResult<Row, Row>, entry: {
119
+ dataset: Key;
120
+ ctx: Ctx;
121
+ }): unknown;
93
122
  };
94
123
  export type DatasetCatalogEntry<Key extends string = string> = {
95
124
  key: Key;
@@ -103,6 +132,8 @@ export type DatasetCatalogEntry<Key extends string = string> = {
103
132
  key: string;
104
133
  label: string;
105
134
  description: string | undefined;
135
+ /** `count`: a whole number, never null. `ratio`: 0-1 (`share`, `ratio`). `number`: the rest. */
136
+ valueType: 'count' | 'number' | 'ratio';
106
137
  }[];
107
138
  dimensions: {
108
139
  key: string;
@@ -143,6 +174,11 @@ export type Analytics<DS extends readonly AnyDataset[], Ctx> = {
143
174
  }): Promise<z.ZodType<DatasetQuery<DS[number]>>>;
144
175
  /** The result schema for one dataset. */
145
176
  resultSchema(dataset: DS[number]['key']): z.ZodType<QueryResult<Row, Row>>;
177
+ /**
178
+ * The timezone `ctx`'s periods and buckets resolve in: the `timezone` hook's answer, UTC
179
+ * when it has none. Rejects with `invalid_timezone` for a name the runtime does not know.
180
+ */
181
+ timezone(ctx: Ctx): Promise<string>;
146
182
  /** The datasets `ctx` may query, with the measures and dimensions it may use. */
147
183
  listDatasets(ctx: Ctx): Promise<DatasetCatalogEntry<DS[number]['key']>[]>;
148
184
  };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Serving queries from any server framework: several queries in one request (all at once or
3
+ * as each finishes), and engine errors as HTTP errors. Framework-neutral; an oRPC or tRPC
4
+ * procedure wraps these in a few lines.
5
+ */
6
+ import { type Analytics, AnalyticsError, type AnalyticsErrorCode, type AnalyticsErrorDetails } from './analytics.js';
7
+ import type { AnyDataset } from './dataset.js';
8
+ import type { DatasetQuery, ResultOf } from './query.js';
9
+ /** Why one query of a batch failed. Errors other than `AnalyticsError` are not described. */
10
+ export type BatchError = {
11
+ code: AnalyticsErrorCode | 'internal';
12
+ message: string;
13
+ };
14
+ export type BatchResult<DS extends readonly AnyDataset[], Q> = {
15
+ results: {
16
+ [K in keyof Q]?: ResultOf<DS[number], Q[K]>;
17
+ };
18
+ errors: {
19
+ [K in keyof Q]?: BatchError;
20
+ };
21
+ };
22
+ export type BatchChunk<DS extends readonly AnyDataset[], Q> = {
23
+ [K in keyof Q & string]: {
24
+ key: K;
25
+ result: ResultOf<DS[number], Q[K]>;
26
+ } | {
27
+ key: K;
28
+ error: BatchError;
29
+ };
30
+ }[keyof Q & string];
31
+ type Queries<DS extends readonly AnyDataset[]> = Record<string, DatasetQuery<DS[number]>>;
32
+ /**
33
+ * Runs named queries in parallel. One query failing does not fail the others: its error is
34
+ * under its name. Each query is authorized, cached and reported like a single `query`.
35
+ */
36
+ export declare function queryBatch<DS extends readonly AnyDataset[], Ctx, const Q extends Queries<DS>>(analytics: Analytics<DS, Ctx>, queries: Q, ctx: Ctx): Promise<BatchResult<DS, Q>>;
37
+ /** Runs named queries in parallel and yields each as it finishes, for a streaming endpoint. */
38
+ export declare function streamBatch<DS extends readonly AnyDataset[], Ctx, const Q extends Queries<DS>>(analytics: Analytics<DS, Ctx>, queries: Q, ctx: Ctx): AsyncGenerator<BatchChunk<DS, Q>, void, undefined>;
39
+ export type HttpError = {
40
+ status: 400 | 401 | 403;
41
+ /** The status name oRPC and tRPC use for it. */
42
+ code: 'BAD_REQUEST' | 'UNAUTHORIZED' | 'FORBIDDEN';
43
+ message: string;
44
+ /** For a bad request: the engine's code and details, so a model can fix its query. */
45
+ data?: {
46
+ code: AnalyticsErrorCode;
47
+ details: AnalyticsErrorDetails[AnalyticsErrorCode];
48
+ };
49
+ };
50
+ /**
51
+ * An engine error as an HTTP error: `forbidden` is 403, `missing_tenant` 401, the rest are
52
+ * 400 with the code and details. Messages are safe to show; for `unknown_value` they list the
53
+ * valid values.
54
+ */
55
+ export declare function toHttpError(error: AnalyticsError): HttpError;
56
+ export {};
@@ -0,0 +1,11 @@
1
+ import {
2
+ queryBatch2,
3
+ streamBatch2,
4
+ toHttpError2
5
+ } from "./index-xf6qpmgy.js";
6
+ import"./index-z5xv3ffk.js";
7
+ export {
8
+ queryBatch2 as queryBatch,
9
+ streamBatch2 as streamBatch,
10
+ toHttpError2 as toHttpError
11
+ };
@@ -0,0 +1,149 @@
1
+ // src/period.ts
2
+ var MAX_PERIOD_DAYS = 731;
3
+ var DEFAULT_PERIOD = {
4
+ last: { days: 30 }
5
+ };
6
+ var DAY_MS = 86400000;
7
+ var formatterCache = new Map;
8
+ function formatter(timezone) {
9
+ let cached = formatterCache.get(timezone);
10
+ if (!cached) {
11
+ cached = new Intl.DateTimeFormat("en-US", {
12
+ timeZone: timezone,
13
+ hourCycle: "h23",
14
+ year: "numeric",
15
+ month: "2-digit",
16
+ day: "2-digit",
17
+ hour: "2-digit",
18
+ minute: "2-digit",
19
+ second: "2-digit"
20
+ });
21
+ formatterCache.set(timezone, cached);
22
+ }
23
+ return cached;
24
+ }
25
+ function isValidTimezone(timezone) {
26
+ if (!/^[A-Za-z][A-Za-z0-9_+\-/]*$/.test(timezone))
27
+ return false;
28
+ try {
29
+ formatter(timezone);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+ function localParts(instant, timezone) {
36
+ const parts = {};
37
+ for (const part of formatter(timezone).formatToParts(instant)) {
38
+ if (part.type !== "literal")
39
+ parts[part.type] = Number(part.value);
40
+ }
41
+ return {
42
+ year: parts.year ?? 0,
43
+ month: parts.month ?? 1,
44
+ day: parts.day ?? 1,
45
+ hour: parts.hour ?? 0,
46
+ minute: parts.minute ?? 0,
47
+ second: parts.second ?? 0
48
+ };
49
+ }
50
+ function offsetAt(instant, timezone) {
51
+ const p = localParts(instant, timezone);
52
+ const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
53
+ return asUtc - (instant.getTime() - instant.getUTCMilliseconds());
54
+ }
55
+ function localDate(instant, timezone) {
56
+ const p = localParts(instant, timezone);
57
+ return formatDate(Date.UTC(p.year, p.month - 1, p.day));
58
+ }
59
+ function startOfLocalDay(date, timezone) {
60
+ const wallClock = parseDate(date);
61
+ const firstGuess = wallClock - offsetAt(new Date(wallClock), timezone);
62
+ const corrected = wallClock - offsetAt(new Date(firstGuess), timezone);
63
+ return new Date(corrected);
64
+ }
65
+ function parseDate(date) {
66
+ const [year, month, day] = date.split("-").map(Number);
67
+ return Date.UTC(year ?? 0, (month ?? 1) - 1, day ?? 1);
68
+ }
69
+ function formatDate(utcMs) {
70
+ return new Date(utcMs).toISOString().slice(0, 10);
71
+ }
72
+ function addDays(date, days) {
73
+ return formatDate(parseDate(date) + days * DAY_MS);
74
+ }
75
+ function daysBetween(from, to) {
76
+ return Math.round((parseDate(to) - parseDate(from)) / DAY_MS);
77
+ }
78
+ function isoWeekday(date) {
79
+ const day = new Date(parseDate(date)).getUTCDay();
80
+ return day === 0 ? 7 : day;
81
+ }
82
+
83
+ class PeriodError extends Error {
84
+ name = "PeriodError";
85
+ }
86
+ function resolvePeriod(input, timezone, now) {
87
+ const today = localDate(now, timezone);
88
+ const [from, to] = periodDays(input, today);
89
+ if (daysBetween(from, to) < 0) {
90
+ throw new PeriodError(`The period starts (${from}) after it ends (${to}).`);
91
+ }
92
+ return periodFromDays(from, to, timezone);
93
+ }
94
+ function periodDays(input, today) {
95
+ if ("last" in input)
96
+ return [addDays(today, -(input.last.days - 1)), today];
97
+ if ("from" in input)
98
+ return [input.from, input.to];
99
+ const monthStart = `${today.slice(0, 8)}01`;
100
+ const weekStart = addDays(today, -(isoWeekday(today) - 1));
101
+ switch (input.preset) {
102
+ case "today":
103
+ return [today, today];
104
+ case "yesterday":
105
+ return [addDays(today, -1), addDays(today, -1)];
106
+ case "thisWeek":
107
+ return [weekStart, today];
108
+ case "lastWeek":
109
+ return [addDays(weekStart, -7), addDays(weekStart, -1)];
110
+ case "thisMonth":
111
+ return [monthStart, today];
112
+ case "lastMonth": {
113
+ const lastMonthEnd = addDays(monthStart, -1);
114
+ return [`${lastMonthEnd.slice(0, 8)}01`, lastMonthEnd];
115
+ }
116
+ case "thisYear":
117
+ return [`${today.slice(0, 4)}-01-01`, today];
118
+ }
119
+ }
120
+ function periodFromDays(from, to, timezone) {
121
+ const days = daysBetween(from, to) + 1;
122
+ if (days > MAX_PERIOD_DAYS) {
123
+ throw new PeriodError(`A period may cover at most ${MAX_PERIOD_DAYS} days (two years); ${from} to ${to} is ${days} days.`);
124
+ }
125
+ return {
126
+ from,
127
+ to,
128
+ timezone,
129
+ start: startOfLocalDay(from, timezone),
130
+ end: startOfLocalDay(addDays(to, 1), timezone),
131
+ days
132
+ };
133
+ }
134
+ function previousPeriod(period) {
135
+ const to = addDays(period.from, -1);
136
+ const from = addDays(to, -(period.days - 1));
137
+ return periodFromDays(from, to, period.timezone);
138
+ }
139
+ function bucketStarts(from, to, unit) {
140
+ let start = unit === "day" ? from : unit === "week" ? addDays(from, -(isoWeekday(from) - 1)) : `${from.slice(0, 8)}01`;
141
+ const starts = [];
142
+ while (start <= to) {
143
+ starts.push(start);
144
+ start = unit === "day" ? addDays(start, 1) : unit === "week" ? addDays(start, 7) : formatDate(Date.UTC(Number(start.slice(0, 4)), Number(start.slice(5, 7)), 1));
145
+ }
146
+ return starts;
147
+ }
148
+
149
+ export { MAX_PERIOD_DAYS, DEFAULT_PERIOD, isValidTimezone, localDate, PeriodError, resolvePeriod, previousPeriod, bucketStarts };