measurr 0.4.0 → 0.5.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 +101 -13
- package/dist/analytics.d.ts +31 -0
- package/dist/handler.d.ts +56 -0
- package/dist/handler.js +49 -0
- package/dist/index-g255jb1s.js +149 -0
- package/dist/index-g2mjqx4f.js +303 -0
- package/dist/{index-4rmjas9h.js → index-zgb1e89d.js} +67 -445
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -5
- package/dist/period.d.ts +5 -0
- package/dist/shapes.d.ts +111 -0
- package/dist/shapes.js +134 -0
- package/dist/testing.js +4 -2
- package/package.json +14 -2
- package/src/analytics.ts +141 -15
- package/src/handler.ts +124 -0
- package/src/index.ts +1 -0
- package/src/period.ts +32 -0
- package/src/shapes.ts +295 -0
package/README.md
CHANGED
|
@@ -176,9 +176,95 @@ ${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
|
|
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
|
|
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
|
+
## Serving queries
|
|
246
|
+
|
|
247
|
+
`measurr/handler` serves queries from any server framework:
|
|
248
|
+
|
|
249
|
+
- `queryBatch(analytics, { total: q1, byStatus: q2 }, ctx)` runs named queries in parallel and
|
|
250
|
+
resolves to `{ results, errors }`, typed per name. One failing query does not fail the
|
|
251
|
+
others; an error other than `AnalyticsError` is reported as `{ code: 'internal' }` without
|
|
252
|
+
its message.
|
|
253
|
+
- `streamBatch(analytics, queries, ctx)` yields `{ key, result }` or `{ key, error }` as each
|
|
254
|
+
query finishes, for a streaming endpoint.
|
|
255
|
+
- `toHttpError(error)` maps an `AnalyticsError` to `{ status, code, message, data? }`:
|
|
256
|
+
`forbidden` is 403 `FORBIDDEN`, `missing_tenant` 401 `UNAUTHORIZED`, the rest 400
|
|
257
|
+
`BAD_REQUEST` with the code and details. The codes are the names oRPC and tRPC use:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
try {
|
|
261
|
+
return await analytics.query(input, ctx)
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (!(error instanceof AnalyticsError)) throw error
|
|
264
|
+
const { code, message, data } = toHttpError(error)
|
|
265
|
+
throw new ORPCError(code, { message, data })
|
|
266
|
+
}
|
|
267
|
+
```
|
|
182
268
|
|
|
183
269
|
## Tenants and platform admins
|
|
184
270
|
|
|
@@ -420,16 +506,18 @@ await assertDatasetContract({ dataset: maintenance, source, tenant: allTenants }
|
|
|
420
506
|
|
|
421
507
|
## Stable API
|
|
422
508
|
|
|
423
|
-
Kept stable for publishing: `createAnalytics` and its options (with `authorize
|
|
424
|
-
`authorizeField`), `allTenants`, `analytics.query`, `explain`, `querySchema`,
|
|
425
|
-
`listDatasets`; `AnalyticsResultSchema`, `analyticsToolGuidance`,
|
|
426
|
-
`defineDataset` (with `defaultPeriod` and `tenantColumn: null`),
|
|
427
|
-
(with `access`), `table` / `Table`, the expression builders; the
|
|
428
|
-
`SqlRenderer` and `AnalyticsSource` contracts; `postgresDialect`,
|
|
429
|
-
`assertDatasetContract`;
|
|
430
|
-
`
|
|
431
|
-
|
|
432
|
-
|
|
509
|
+
Kept stable for publishing: `createAnalytics` and its options (with `authorize`,
|
|
510
|
+
`authorizeField` and `cache`), `allTenants`, `analytics.query`, `explain`, `querySchema`,
|
|
511
|
+
`resultSchema`, `listDatasets`; `AnalyticsResultSchema`, `analyticsToolGuidance`,
|
|
512
|
+
`AnalyticsQueryEvent`; `defineDataset` (with `defaultPeriod` and `tenantColumn: null`),
|
|
513
|
+
`measure.*` and `dimension.*` (with `access`), `table` / `Table`, the expression builders; the
|
|
514
|
+
`Dialect`, `SqlRenderer` and `AnalyticsSource` contracts; `postgresDialect`,
|
|
515
|
+
`checkDatasetContract` / `assertDatasetContract`; `measurr/shapes` (`toKpi`, `toTimeseries`,
|
|
516
|
+
`toDistribution`, `toPivot`, `toLeaderboard`) and `measurr/handler` (`queryBatch`,
|
|
517
|
+
`streamBatch`, `toHttpError`); and the types `AnalyticsQuery`, `AnalyticsResult`,
|
|
518
|
+
`ResultPeriod`, `PeriodInput`, `Tenant`, `TenantId`, `AccessKey`, `AnalyticsCache`, `FieldKey`,
|
|
519
|
+
`Ratio`, `AnalyticsError` codes and their details (`isAnalyticsError`). `ExprNode` and
|
|
520
|
+
`SelectStatement` are exported for dialect authors and may grow new node kinds.
|
|
433
521
|
|
|
434
522
|
## Install
|
|
435
523
|
|
package/dist/analytics.d.ts
CHANGED
|
@@ -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;
|
|
@@ -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 {};
|
package/dist/handler.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnalyticsError2
|
|
3
|
+
} from "./index-zgb1e89d.js";
|
|
4
|
+
|
|
5
|
+
// src/handler.ts
|
|
6
|
+
async function queryBatch(analytics, queries, ctx) {
|
|
7
|
+
const results = {};
|
|
8
|
+
const errors = {};
|
|
9
|
+
for await (const chunk of streamBatch(analytics, queries, ctx)) {
|
|
10
|
+
if ("error" in chunk)
|
|
11
|
+
errors[chunk.key] = chunk.error;
|
|
12
|
+
else
|
|
13
|
+
results[chunk.key] = chunk.result;
|
|
14
|
+
}
|
|
15
|
+
return { results, errors };
|
|
16
|
+
}
|
|
17
|
+
async function* streamBatch(analytics, queries, ctx) {
|
|
18
|
+
const pending = new Map(Object.entries(queries).map(([key, query]) => {
|
|
19
|
+
const settled = analytics.query(query, ctx).then((result) => ({ key, result }), (error) => ({ key, error: batchError(error) }));
|
|
20
|
+
return [key, settled];
|
|
21
|
+
}));
|
|
22
|
+
while (pending.size > 0) {
|
|
23
|
+
const chunk = await Promise.race(pending.values());
|
|
24
|
+
pending.delete(chunk.key);
|
|
25
|
+
yield chunk;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function batchError(error) {
|
|
29
|
+
return error instanceof AnalyticsError2 ? { code: error.code, message: error.message } : { code: "internal", message: "The query failed" };
|
|
30
|
+
}
|
|
31
|
+
function toHttpError(error) {
|
|
32
|
+
if (error.code === "forbidden") {
|
|
33
|
+
return { status: 403, code: "FORBIDDEN", message: error.message };
|
|
34
|
+
}
|
|
35
|
+
if (error.code === "missing_tenant") {
|
|
36
|
+
return { status: 401, code: "UNAUTHORIZED", message: error.message };
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
status: 400,
|
|
40
|
+
code: "BAD_REQUEST",
|
|
41
|
+
message: error.message,
|
|
42
|
+
data: { code: error.code, details: error.details }
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export {
|
|
46
|
+
queryBatch,
|
|
47
|
+
streamBatch,
|
|
48
|
+
toHttpError
|
|
49
|
+
};
|
|
@@ -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, PeriodError, resolvePeriod, previousPeriod, bucketStarts };
|