measurr 0.1.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/LICENSE +21 -0
- package/README.md +340 -0
- package/dist/analytics.d.ts +133 -0
- package/dist/compile.d.ts +84 -0
- package/dist/dataset.d.ts +303 -0
- package/dist/dialects/postgres.d.ts +16 -0
- package/dist/dialects/postgres.js +51 -0
- package/dist/expr.d.ts +210 -0
- package/dist/guidance.d.ts +5 -0
- package/dist/index-e6k30sfp.js +218 -0
- package/dist/index-m0czpsyv.js +1292 -0
- package/dist/index-nrjdssft.js +200 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +78 -0
- package/dist/period.d.ts +44 -0
- package/dist/plan.d.ts +86 -0
- package/dist/query.d.ts +619 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +191 -0
- package/package.json +84 -0
- package/src/analytics.ts +663 -0
- package/src/compile.ts +332 -0
- package/src/dataset.ts +783 -0
- package/src/dialects/postgres.ts +74 -0
- package/src/expr.ts +539 -0
- package/src/guidance.ts +10 -0
- package/src/index.ts +74 -0
- package/src/period.ts +212 -0
- package/src/plan.ts +588 -0
- package/src/query.ts +399 -0
- package/src/testing.ts +283 -0
package/src/period.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Periods are whole local days in the tenant's timezone. A period `{ from, to }` covers
|
|
3
|
+
* `from` 00:00 up to (not including) the day after `to` 00:00, converted to instants, so the
|
|
4
|
+
* database compares plain timestamps and can use its time index.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type PeriodPreset =
|
|
8
|
+
| 'today'
|
|
9
|
+
| 'yesterday'
|
|
10
|
+
| 'thisWeek'
|
|
11
|
+
| 'lastWeek'
|
|
12
|
+
| 'thisMonth'
|
|
13
|
+
| 'lastMonth'
|
|
14
|
+
| 'thisYear'
|
|
15
|
+
|
|
16
|
+
export type PeriodInput =
|
|
17
|
+
| { last: { days: number } }
|
|
18
|
+
| { from: string; to: string }
|
|
19
|
+
| { preset: PeriodPreset }
|
|
20
|
+
|
|
21
|
+
export type ResolvedPeriod = {
|
|
22
|
+
/** First local day, `YYYY-MM-DD`. */
|
|
23
|
+
from: string
|
|
24
|
+
/** Last local day, inclusive. */
|
|
25
|
+
to: string
|
|
26
|
+
timezone: string
|
|
27
|
+
/** Inclusive start instant. */
|
|
28
|
+
start: Date
|
|
29
|
+
/** Exclusive end instant. */
|
|
30
|
+
end: Date
|
|
31
|
+
days: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const MAX_PERIOD_DAYS = 731
|
|
35
|
+
export const DEFAULT_PERIOD: PeriodInput = { last: { days: 30 } }
|
|
36
|
+
|
|
37
|
+
const DAY_MS = 86_400_000
|
|
38
|
+
|
|
39
|
+
const formatterCache = new Map<string, Intl.DateTimeFormat>()
|
|
40
|
+
|
|
41
|
+
function formatter(timezone: string): Intl.DateTimeFormat {
|
|
42
|
+
let cached = formatterCache.get(timezone)
|
|
43
|
+
if (!cached) {
|
|
44
|
+
cached = new Intl.DateTimeFormat('en-US', {
|
|
45
|
+
timeZone: timezone,
|
|
46
|
+
hourCycle: 'h23',
|
|
47
|
+
year: 'numeric',
|
|
48
|
+
month: '2-digit',
|
|
49
|
+
day: '2-digit',
|
|
50
|
+
hour: '2-digit',
|
|
51
|
+
minute: '2-digit',
|
|
52
|
+
second: '2-digit'
|
|
53
|
+
})
|
|
54
|
+
formatterCache.set(timezone, cached)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return cached
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True for an IANA name the runtime knows. Names are also inlined in SQL, so be strict. */
|
|
61
|
+
export function isValidTimezone(timezone: string): boolean {
|
|
62
|
+
if (!/^[A-Za-z][A-Za-z0-9_+\-/]*$/.test(timezone)) return false
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
formatter(timezone)
|
|
66
|
+
return true
|
|
67
|
+
} catch {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function localParts(instant: Date, timezone: string) {
|
|
73
|
+
const parts: Record<string, number> = {}
|
|
74
|
+
for (const part of formatter(timezone).formatToParts(instant)) {
|
|
75
|
+
if (part.type !== 'literal') parts[part.type] = Number(part.value)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
year: parts.year ?? 0,
|
|
80
|
+
month: parts.month ?? 1,
|
|
81
|
+
day: parts.day ?? 1,
|
|
82
|
+
hour: parts.hour ?? 0,
|
|
83
|
+
minute: parts.minute ?? 0,
|
|
84
|
+
second: parts.second ?? 0
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Milliseconds the timezone is ahead of UTC at `instant`. */
|
|
89
|
+
function offsetAt(instant: Date, timezone: string): number {
|
|
90
|
+
const p = localParts(instant, timezone)
|
|
91
|
+
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second)
|
|
92
|
+
|
|
93
|
+
return asUtc - (instant.getTime() - instant.getUTCMilliseconds())
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The local calendar date of `instant` in `timezone`, as `YYYY-MM-DD`. */
|
|
97
|
+
export function localDate(instant: Date, timezone: string): string {
|
|
98
|
+
const p = localParts(instant, timezone)
|
|
99
|
+
|
|
100
|
+
return formatDate(Date.UTC(p.year, p.month - 1, p.day))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The instant local midnight starts on `date` in `timezone` (handles DST changes). */
|
|
104
|
+
export function startOfLocalDay(date: string, timezone: string): Date {
|
|
105
|
+
const wallClock = parseDate(date)
|
|
106
|
+
const firstGuess = wallClock - offsetAt(new Date(wallClock), timezone)
|
|
107
|
+
const corrected = wallClock - offsetAt(new Date(firstGuess), timezone)
|
|
108
|
+
|
|
109
|
+
return new Date(corrected)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseDate(date: string): number {
|
|
113
|
+
const [year, month, day] = date.split('-').map(Number)
|
|
114
|
+
|
|
115
|
+
return Date.UTC(year ?? 0, (month ?? 1) - 1, day ?? 1)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function formatDate(utcMs: number): string {
|
|
119
|
+
return new Date(utcMs).toISOString().slice(0, 10)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function addDays(date: string, days: number): string {
|
|
123
|
+
return formatDate(parseDate(date) + days * DAY_MS)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function daysBetween(from: string, to: string): number {
|
|
127
|
+
return Math.round((parseDate(to) - parseDate(from)) / DAY_MS)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 1 = Monday ... 7 = Sunday. */
|
|
131
|
+
function isoWeekday(date: string): number {
|
|
132
|
+
const day = new Date(parseDate(date)).getUTCDay()
|
|
133
|
+
|
|
134
|
+
return day === 0 ? 7 : day
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export class PeriodError extends Error {
|
|
138
|
+
override readonly name = 'PeriodError'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Resolves a period input to local days and instants in `timezone`. */
|
|
142
|
+
export function resolvePeriod(
|
|
143
|
+
input: PeriodInput,
|
|
144
|
+
timezone: string,
|
|
145
|
+
now: Date
|
|
146
|
+
): ResolvedPeriod {
|
|
147
|
+
const today = localDate(now, timezone)
|
|
148
|
+
const [from, to] = periodDays(input, today)
|
|
149
|
+
|
|
150
|
+
if (daysBetween(from, to) < 0) {
|
|
151
|
+
throw new PeriodError(`The period starts (${from}) after it ends (${to}).`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return periodFromDays(from, to, timezone)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function periodDays(input: PeriodInput, today: string): [string, string] {
|
|
158
|
+
if ('last' in input) return [addDays(today, -(input.last.days - 1)), today]
|
|
159
|
+
if ('from' in input) return [input.from, input.to]
|
|
160
|
+
|
|
161
|
+
const monthStart = `${today.slice(0, 8)}01`
|
|
162
|
+
const weekStart = addDays(today, -(isoWeekday(today) - 1))
|
|
163
|
+
|
|
164
|
+
switch (input.preset) {
|
|
165
|
+
case 'today':
|
|
166
|
+
return [today, today]
|
|
167
|
+
case 'yesterday':
|
|
168
|
+
return [addDays(today, -1), addDays(today, -1)]
|
|
169
|
+
case 'thisWeek':
|
|
170
|
+
return [weekStart, today]
|
|
171
|
+
case 'lastWeek':
|
|
172
|
+
return [addDays(weekStart, -7), addDays(weekStart, -1)]
|
|
173
|
+
case 'thisMonth':
|
|
174
|
+
return [monthStart, today]
|
|
175
|
+
case 'lastMonth': {
|
|
176
|
+
const lastMonthEnd = addDays(monthStart, -1)
|
|
177
|
+
return [`${lastMonthEnd.slice(0, 8)}01`, lastMonthEnd]
|
|
178
|
+
}
|
|
179
|
+
case 'thisYear':
|
|
180
|
+
return [`${today.slice(0, 4)}-01-01`, today]
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function periodFromDays(
|
|
185
|
+
from: string,
|
|
186
|
+
to: string,
|
|
187
|
+
timezone: string
|
|
188
|
+
): ResolvedPeriod {
|
|
189
|
+
const days = daysBetween(from, to) + 1
|
|
190
|
+
if (days > MAX_PERIOD_DAYS) {
|
|
191
|
+
throw new PeriodError(
|
|
192
|
+
`A period may cover at most ${MAX_PERIOD_DAYS} days (two years); ${from} to ${to} is ${days} days.`
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
from,
|
|
198
|
+
to,
|
|
199
|
+
timezone,
|
|
200
|
+
start: startOfLocalDay(from, timezone),
|
|
201
|
+
end: startOfLocalDay(addDays(to, 1), timezone),
|
|
202
|
+
days
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The period of equal length that ends the day before `period` starts. */
|
|
207
|
+
export function previousPeriod(period: ResolvedPeriod): ResolvedPeriod {
|
|
208
|
+
const to = addDays(period.from, -1)
|
|
209
|
+
const from = addDays(to, -(period.days - 1))
|
|
210
|
+
|
|
211
|
+
return periodFromDays(from, to, period.timezone)
|
|
212
|
+
}
|