Repository navigation
Expand file tree
/
Copy pathtime.ts
More file actions
118 lines (108 loc) · 3.89 KB
/
Copy pathtime.ts
File metadata and controls
118 lines (108 loc) · 3.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
export const SECOND = 1_000;
export const MINUTE = 60 * SECOND;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
export type Granularity = 'minute' | 'hour' | 'day';
export const GRANULARITIES: readonly Granularity[] = ['minute', 'hour', 'day'];
const formatters = new Map<string, Intl.DateTimeFormat>();
const offsetCache = new Map<string, number>();
const OFFSET_SLOT = 15 * MINUTE; // offsets only ever change on quarter-hour boundaries
function formatter(timeZone: string): Intl.DateTimeFormat {
let f = formatters.get(timeZone);
if (!f) {
f = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
});
formatters.set(timeZone, f);
}
return f;
}
export function isValidTimeZone(timeZone: string): boolean {
try {
formatter(timeZone);
return true;
} catch {
return false;
}
}
/** UTC offset of `timeZone` at instant `ts`, in minutes (e.g. +180 for Europe/Moscow). */
export function tzOffsetMinutes(ts: number, timeZone: string): number {
if (timeZone === 'UTC' || timeZone === 'Etc/UTC') return 0;
const slot = Math.floor(ts / OFFSET_SLOT);
const key = `${timeZone}|${slot}`;
const cached = offsetCache.get(key);
if (cached !== undefined) return cached;
const at = slot * OFFSET_SLOT;
const parts: Record<string, number> = {};
for (const p of formatter(timeZone).formatToParts(at)) {
if (p.type !== 'literal') parts[p.type] = Number(p.value);
}
const wall = Date.UTC(parts.year!, parts.month! - 1, parts.day!, parts.hour!, parts.minute!);
const offset = Math.round((wall - at) / MINUTE);
if (offsetCache.size > 50_000) offsetCache.clear();
offsetCache.set(key, offset);
return offset;
}
/** Local midnight (in `timeZone`) of the day containing `ts`, as epoch ms. */
export function startOfDay(ts: number, timeZone = 'UTC'): number {
const offset = tzOffsetMinutes(ts, timeZone) * MINUTE;
const localMidnight = Math.floor((ts + offset) / DAY) * DAY;
const offsetAtMidnight = tzOffsetMinutes(localMidnight - offset, timeZone) * MINUTE;
return localMidnight - offsetAtMidnight;
}
export function bucketStart(ts: number, granularity: Granularity, timeZone = 'UTC'): number {
switch (granularity) {
case 'minute':
// Every zone offset is a whole number of minutes, so UTC and local
// minute boundaries always coincide.
return Math.floor(ts / MINUTE) * MINUTE;
case 'hour': {
// Align to the local hour, like startOfDay does for the day: half-offset
// zones (e.g. Asia/Kolkata) start their hours at :30 past the UTC hour.
const offset = tzOffsetMinutes(ts, timeZone) * MINUTE;
const localHour = Math.floor((ts + offset) / HOUR) * HOUR;
const offsetAtHour = tzOffsetMinutes(localHour - offset, timeZone) * MINUTE;
return localHour - offsetAtHour;
}
case 'day':
return startOfDay(ts, timeZone);
}
}
export function nextBucket(bucket: number, granularity: Granularity, timeZone = 'UTC'): number {
switch (granularity) {
case 'minute':
return bucket + MINUTE;
case 'hour':
return bucket + HOUR;
case 'day':
// +26h always lands inside the next local day, even across DST changes.
return startOfDay(bucket + DAY + 2 * HOUR, timeZone);
}
}
/** Most buckets {@link bucketsBetween} returns before it throws. */
export const MAX_BUCKETS = 10_000;
/** Bucket starts covering the half-open interval [from, to). */
export function bucketsBetween(
from: number,
to: number,
granularity: Granularity,
timeZone = 'UTC',
): number[] {
const out: number[] = [];
for (
let b = bucketStart(from, granularity, timeZone);
b < to;
b = nextBucket(b, granularity, timeZone)
) {
out.push(b);
if (out.length > MAX_BUCKETS)
throw new RangeError('Range too large for the chosen granularity');
}
return out;
}