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/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
+ }