pond-ts 0.67.0 → 0.69.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.
@@ -0,0 +1,122 @@
1
+ import type { CalendarUnit, WeekStartsOn } from './calendar.js';
2
+ /**
3
+ * How `TimeZone.instant()` resolves a wall-clock time that a transition
4
+ * makes ambiguous (a fall-back repeats an hour) or skips (a spring-forward
5
+ * drops one). Same vocabulary and same defaults as Temporal:
6
+ *
7
+ * - `'compatible'` (default) — the earlier instant when the time repeats,
8
+ * the instant *after* the gap when it is skipped (02:30 on a spring-forward
9
+ * night becomes 03:30). This is also what `startOf('day')` needs on the
10
+ * rare days that have no midnight.
11
+ * - `'earlier'` / `'later'` — always the earlier / later of the two readings.
12
+ * - `'reject'` — throw a `RangeError` for either case.
13
+ */
14
+ export type Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject';
15
+ /** The wall-clock reading of an instant in a zone. `weekday` is ISO: 1 = Monday … 7 = Sunday. */
16
+ export type ZonedParts = {
17
+ year: number;
18
+ month: number;
19
+ day: number;
20
+ hour: number;
21
+ minute: number;
22
+ second: number;
23
+ millisecond: number;
24
+ weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7;
25
+ };
26
+ /** The date-and-time fields `TimeZone.instant()` accepts. Time fields default to zero. */
27
+ export type ZonedPartsInput = {
28
+ year: number;
29
+ month: number;
30
+ day: number;
31
+ hour?: number;
32
+ minute?: number;
33
+ second?: number;
34
+ millisecond?: number;
35
+ };
36
+ export type StartOfOptions = {
37
+ /** First day of the week for `'week'`, ISO numbering (1 = Monday, default) … 7 = Sunday. */
38
+ weekStartsOn?: WeekStartsOn;
39
+ };
40
+ declare function assertCalendarUnit(unit: string): asserts unit is CalendarUnit;
41
+ /**
42
+ * An IANA time zone as a calendar: the one place in pond that turns instants
43
+ * (UTC epoch milliseconds) into wall-clock readings and calendar boundaries,
44
+ * and back.
45
+ *
46
+ * Instances are interned — `TimeZone.of('Europe/Berlin')` returns the same
47
+ * object every time — and cache the zone's offset transitions as they are
48
+ * discovered, so after the first query in a stretch of history every call
49
+ * is integer arithmetic. `Sequence.calendar` buckets with it; the charts'
50
+ * time axis places and labels ticks with it; the two therefore agree on
51
+ * every boundary by construction.
52
+ *
53
+ * A `TimeZone` holds no instant. Values (`Time`, `TimeRange`, `Interval`)
54
+ * stay plain UTC milliseconds; the zone is a parameter to the code that
55
+ * interprets them.
56
+ */
57
+ export declare class TimeZone {
58
+ #private;
59
+ /** The canonical IANA identifier, e.g. `'America/New_York'` or `'UTC'`. */
60
+ readonly id: string;
61
+ private constructor();
62
+ /**
63
+ * Example: `TimeZone.of('Australia/Sydney')`. Looks up a zone by IANA
64
+ * identifier. Throws `RangeError` for an identifier the runtime does not
65
+ * know. Case-insensitive on input; `id` reports the canonical spelling.
66
+ * A fixed-offset identifier such as `'+05:30'` is accepted too, as
67
+ * Temporal accepts it, and behaves as a zone with a single segment.
68
+ */
69
+ static of(id: string): TimeZone;
70
+ /** Example: `TimeZone.UTC.startOf('month', t)`. The UTC zone. */
71
+ static get UTC(): TimeZone;
72
+ /**
73
+ * Example: `TimeZone.local()`. The runtime's own zone, as the environment
74
+ * reports it. Resolved on every call, so a test that changes `TZ` between
75
+ * calls sees the change.
76
+ */
77
+ static local(): TimeZone;
78
+ /** Example: `zone.offsetAt(t)`. The zone's UTC offset at an instant, in milliseconds east of UTC. */
79
+ offsetAt(ms: number): number;
80
+ /**
81
+ * Example: `zone.parts(t).hour`. The wall-clock reading of an instant in
82
+ * this zone.
83
+ */
84
+ parts(ms: number): ZonedParts;
85
+ /**
86
+ * Example: `zone.instant({ year: 2026, month: 3, day: 29, hour: 2, minute: 30 })`.
87
+ * The instant at which this zone's clocks read the given wall time. When
88
+ * a transition makes the reading ambiguous or nonexistent,
89
+ * `disambiguation` decides (default `'compatible'`, as Temporal).
90
+ */
91
+ instant(parts: ZonedPartsInput, options?: {
92
+ disambiguation?: Disambiguation;
93
+ }): number;
94
+ /**
95
+ * Example: `zone.startOf('week', t, { weekStartsOn: 7 })`. The first
96
+ * instant of the calendar unit containing `ms`, in this zone. On a day
97
+ * with no midnight (a spring-forward at 00:00) this is the first instant
98
+ * that exists, as `Temporal.ZonedDateTime.startOfDay` does.
99
+ */
100
+ startOf(unit: CalendarUnit, ms: number, options?: StartOfOptions): number;
101
+ /**
102
+ * Example: `zone.next('month', t)`. The first instant of the calendar unit
103
+ * *after* the one containing `ms` — i.e. the exclusive end of `startOf`'s
104
+ * bucket.
105
+ */
106
+ next(unit: CalendarUnit, ms: number, options?: StartOfOptions): number;
107
+ /**
108
+ * Example: `zone.abbreviation(t)` → `'EDT'`. The zone's short name at an
109
+ * instant, as `Intl` reports it for `locale` (default: the runtime's
110
+ * locale). Which zones have a conventional abbreviation is a locale
111
+ * question — `en-US` knows `EST`, `en-AU` knows `AEDT`, and neither knows
112
+ * the other's; the fallback is a `GMT±h[:mm]` offset string. Pass the
113
+ * viewer's locale when rendering.
114
+ */
115
+ abbreviation(ms: number, options?: {
116
+ locale?: string;
117
+ }): string;
118
+ toString(): string;
119
+ toJSON(): string;
120
+ }
121
+ export { assertCalendarUnit };
122
+ //# sourceMappingURL=time-zone.d.ts.map
@@ -0,0 +1,413 @@
1
+ var _a;
2
+ import { Temporal } from '@js-temporal/polyfill';
3
+ const MS_PER_DAY = 86_400_000;
4
+ const MS_PER_HOUR = 3_600_000;
5
+ /**
6
+ * Half-window either side of a civil time within which every candidate
7
+ * offset for that wall time must lie. IANA offsets span −12:00 … +14:00, and
8
+ * the two offsets bracketing a transition differ by at most a few hours, so
9
+ * ±16 h comfortably covers both sides of any transition.
10
+ */
11
+ const CANDIDATE_WINDOW_MS = 16 * MS_PER_HOUR;
12
+ /**
13
+ * Days since 1970-01-01 for a proleptic-Gregorian civil date (Howard
14
+ * Hinnant's algorithm, valid for the whole `Date` range).
15
+ */
16
+ function daysFromCivil(year, month, day) {
17
+ const y = month <= 2 ? year - 1 : year;
18
+ const era = Math.floor(y / 400);
19
+ const yoe = y - era * 400;
20
+ const mp = (month + 9) % 12;
21
+ const doy = Math.floor((153 * mp + 2) / 5) + day - 1;
22
+ const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
23
+ return era * 146_097 + doe - 719_468;
24
+ }
25
+ /** Inverse of `daysFromCivil`: `[year, month, day]` for a day number. */
26
+ function civilFromDays(days) {
27
+ const z = days + 719_468;
28
+ const era = Math.floor(z / 146_097);
29
+ const doe = z - era * 146_097;
30
+ const yoe = Math.floor((doe -
31
+ Math.floor(doe / 1460) +
32
+ Math.floor(doe / 36_524) -
33
+ Math.floor(doe / 146_096)) /
34
+ 365);
35
+ const y = yoe + era * 400;
36
+ const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
37
+ const mp = Math.floor((5 * doy + 2) / 153);
38
+ const d = doy - Math.floor((153 * mp + 2) / 5) + 1;
39
+ const m = mp < 10 ? mp + 3 : mp - 9;
40
+ return [m <= 2 ? y + 1 : y, m, d];
41
+ }
42
+ /** Days in a proleptic-Gregorian month. */
43
+ function daysInMonth(year, month) {
44
+ return (daysFromCivil(month === 12 ? year + 1 : year, (month % 12) + 1, 1) -
45
+ daysFromCivil(year, month, 1));
46
+ }
47
+ /** ISO weekday (1 = Monday … 7 = Sunday) for a day number; day 0 was a Thursday. */
48
+ function weekdayFromDays(days) {
49
+ return ((((days % 7) + 7 + 3) % 7) + 1);
50
+ }
51
+ function floorMs(ms, label) {
52
+ if (!Number.isFinite(ms)) {
53
+ throw new RangeError(`${label} must be a finite epoch millisecond`);
54
+ }
55
+ // Floor, not trunc: before 1970 they disagree, and -5.5 lies inside the
56
+ // millisecond spanning [-6, -5). See `toPlainDateStart` for why a fraction
57
+ // is not a caller error (a chart's wheel zoom hands us `1.7e12 + 0.37`).
58
+ return Math.floor(ms);
59
+ }
60
+ function assertCalendarUnit(unit) {
61
+ switch (unit) {
62
+ case 'day':
63
+ case 'week':
64
+ case 'month':
65
+ case 'quarter':
66
+ case 'year':
67
+ return;
68
+ default:
69
+ throw new RangeError(`unknown calendar unit ${JSON.stringify(unit)}; expected 'day', 'week', 'month', 'quarter' or 'year'`);
70
+ }
71
+ }
72
+ const registry = new Map();
73
+ /**
74
+ * An IANA time zone as a calendar: the one place in pond that turns instants
75
+ * (UTC epoch milliseconds) into wall-clock readings and calendar boundaries,
76
+ * and back.
77
+ *
78
+ * Instances are interned — `TimeZone.of('Europe/Berlin')` returns the same
79
+ * object every time — and cache the zone's offset transitions as they are
80
+ * discovered, so after the first query in a stretch of history every call
81
+ * is integer arithmetic. `Sequence.calendar` buckets with it; the charts'
82
+ * time axis places and labels ticks with it; the two therefore agree on
83
+ * every boundary by construction.
84
+ *
85
+ * A `TimeZone` holds no instant. Values (`Time`, `TimeRange`, `Interval`)
86
+ * stay plain UTC milliseconds; the zone is a parameter to the code that
87
+ * interprets them.
88
+ */
89
+ export class TimeZone {
90
+ /** The canonical IANA identifier, e.g. `'America/New_York'` or `'UTC'`. */
91
+ id;
92
+ /** Offset segments sorted by `start`, non-overlapping. */
93
+ #segments = [];
94
+ /** The segment most recently hit — almost every query lands here. */
95
+ #last;
96
+ #abbreviationFormats;
97
+ constructor(id) {
98
+ this.id = id;
99
+ Object.freeze(this);
100
+ }
101
+ /**
102
+ * Example: `TimeZone.of('Australia/Sydney')`. Looks up a zone by IANA
103
+ * identifier. Throws `RangeError` for an identifier the runtime does not
104
+ * know. Case-insensitive on input; `id` reports the canonical spelling.
105
+ * A fixed-offset identifier such as `'+05:30'` is accepted too, as
106
+ * Temporal accepts it, and behaves as a zone with a single segment.
107
+ */
108
+ static of(id) {
109
+ const hit = registry.get(id);
110
+ if (hit !== undefined)
111
+ return hit;
112
+ let canonical;
113
+ try {
114
+ canonical =
115
+ Temporal.Instant.fromEpochMilliseconds(0).toZonedDateTimeISO(id).timeZoneId;
116
+ }
117
+ catch {
118
+ throw new RangeError(`unknown time zone ${JSON.stringify(id)}`);
119
+ }
120
+ const existing = registry.get(canonical);
121
+ const zone = existing ?? new _a(canonical);
122
+ registry.set(id, zone);
123
+ registry.set(canonical, zone);
124
+ return zone;
125
+ }
126
+ /** Example: `TimeZone.UTC.startOf('month', t)`. The UTC zone. */
127
+ static get UTC() {
128
+ return _a.of('UTC');
129
+ }
130
+ /**
131
+ * Example: `TimeZone.local()`. The runtime's own zone, as the environment
132
+ * reports it. Resolved on every call, so a test that changes `TZ` between
133
+ * calls sees the change.
134
+ */
135
+ static local() {
136
+ return _a.of(Intl.DateTimeFormat().resolvedOptions().timeZone);
137
+ }
138
+ /** Example: `zone.offsetAt(t)`. The zone's UTC offset at an instant, in milliseconds east of UTC. */
139
+ offsetAt(ms) {
140
+ return this.#segmentAt(floorMs(ms, 'instant')).offsetMs;
141
+ }
142
+ /**
143
+ * Example: `zone.parts(t).hour`. The wall-clock reading of an instant in
144
+ * this zone.
145
+ */
146
+ parts(ms) {
147
+ const t = floorMs(ms, 'instant');
148
+ const civil = t + this.#segmentAt(t).offsetMs;
149
+ return _a.#civilParts(civil);
150
+ }
151
+ /**
152
+ * Example: `zone.instant({ year: 2026, month: 3, day: 29, hour: 2, minute: 30 })`.
153
+ * The instant at which this zone's clocks read the given wall time. When
154
+ * a transition makes the reading ambiguous or nonexistent,
155
+ * `disambiguation` decides (default `'compatible'`, as Temporal).
156
+ */
157
+ instant(parts, options = {}) {
158
+ const civil = _a.#civilMs(parts);
159
+ return this.#instantForCivil(civil, options.disambiguation ?? 'compatible');
160
+ }
161
+ /**
162
+ * Example: `zone.startOf('week', t, { weekStartsOn: 7 })`. The first
163
+ * instant of the calendar unit containing `ms`, in this zone. On a day
164
+ * with no midnight (a spring-forward at 00:00) this is the first instant
165
+ * that exists, as `Temporal.ZonedDateTime.startOfDay` does.
166
+ */
167
+ startOf(unit, ms, options = {}) {
168
+ assertCalendarUnit(unit);
169
+ const t = floorMs(ms, 'instant');
170
+ const civil = t + this.#segmentAt(t).offsetMs;
171
+ const days = Math.floor(civil / MS_PER_DAY);
172
+ return this.#instantForCivil(_a.#startDays(unit, days, options.weekStartsOn ?? 1) * MS_PER_DAY, 'compatible');
173
+ }
174
+ /**
175
+ * Example: `zone.next('month', t)`. The first instant of the calendar unit
176
+ * *after* the one containing `ms` — i.e. the exclusive end of `startOf`'s
177
+ * bucket.
178
+ */
179
+ next(unit, ms, options = {}) {
180
+ assertCalendarUnit(unit);
181
+ const t = floorMs(ms, 'instant');
182
+ const civil = t + this.#segmentAt(t).offsetMs;
183
+ const days = Math.floor(civil / MS_PER_DAY);
184
+ const startDays = _a.#startDays(unit, days, options.weekStartsOn ?? 1);
185
+ return this.#instantForCivil(_a.#advanceDays(unit, startDays) * MS_PER_DAY, 'compatible');
186
+ }
187
+ /**
188
+ * Example: `zone.abbreviation(t)` → `'EDT'`. The zone's short name at an
189
+ * instant, as `Intl` reports it for `locale` (default: the runtime's
190
+ * locale). Which zones have a conventional abbreviation is a locale
191
+ * question — `en-US` knows `EST`, `en-AU` knows `AEDT`, and neither knows
192
+ * the other's; the fallback is a `GMT±h[:mm]` offset string. Pass the
193
+ * viewer's locale when rendering.
194
+ */
195
+ abbreviation(ms, options = {}) {
196
+ const t = floorMs(ms, 'instant');
197
+ const segment = this.#segmentAt(t);
198
+ const locale = options.locale ?? '';
199
+ const cached = segment.abbreviations?.get(locale);
200
+ if (cached !== undefined)
201
+ return cached;
202
+ this.#abbreviationFormats ??= new Map();
203
+ let format = this.#abbreviationFormats.get(locale);
204
+ if (format === undefined) {
205
+ format = new Intl.DateTimeFormat(locale === '' ? undefined : locale, {
206
+ timeZone: this.id,
207
+ timeZoneName: 'short',
208
+ });
209
+ this.#abbreviationFormats.set(locale, format);
210
+ }
211
+ const part = format
212
+ .formatToParts(new Date(t))
213
+ .find((p) => p.type === 'timeZoneName');
214
+ const abbreviation = part?.value ?? this.id;
215
+ (segment.abbreviations ??= new Map()).set(locale, abbreviation);
216
+ return abbreviation;
217
+ }
218
+ toString() {
219
+ return this.id;
220
+ }
221
+ toJSON() {
222
+ return this.id;
223
+ }
224
+ // ---- internals ---------------------------------------------------------
225
+ #segmentAt(t) {
226
+ const last = this.#last;
227
+ if (last !== undefined && t >= last.start && t < last.end)
228
+ return last;
229
+ const segments = this.#segments;
230
+ // Binary search for the segment whose start is the greatest <= t.
231
+ let lo = 0;
232
+ let hi = segments.length - 1;
233
+ while (lo <= hi) {
234
+ const mid = (lo + hi) >> 1;
235
+ const s = segments[mid];
236
+ if (t < s.start)
237
+ hi = mid - 1;
238
+ else if (t >= s.end)
239
+ lo = mid + 1;
240
+ else {
241
+ this.#last = s;
242
+ return s;
243
+ }
244
+ }
245
+ const created = this.#discoverSegment(t);
246
+ segments.splice(lo, 0, created);
247
+ this.#last = created;
248
+ return created;
249
+ }
250
+ /**
251
+ * Ask Temporal for the offset at `t` and the transitions bracketing it.
252
+ *
253
+ * `getTimeZoneTransition('previous')` is *strictly* before its receiver,
254
+ * while the offset at a transition instant is already the new one — so
255
+ * asking at `t` itself, when `t` *is* a transition, would pair the new
256
+ * offset with the start of the old segment and poison the cache for the
257
+ * whole preceding stretch (every hourly series crosses a DST instant
258
+ * exactly). Asking from `t + 1 ms` returns transitions `<= t`, which is
259
+ * the segment start we want; `'next'` from `t` is strictly after, which
260
+ * is the exclusive end we want.
261
+ */
262
+ #discoverSegment(t) {
263
+ const zoned = Temporal.Instant.fromEpochMilliseconds(t).toZonedDateTimeISO(this.id);
264
+ const previous = Temporal.Instant.fromEpochMilliseconds(t + 1)
265
+ .toZonedDateTimeISO(this.id)
266
+ .getTimeZoneTransition('previous');
267
+ const next = zoned.getTimeZoneTransition('next');
268
+ return {
269
+ start: previous === null ? -Infinity : previous.epochMilliseconds,
270
+ end: next === null ? Infinity : next.epochMilliseconds,
271
+ offsetMs: zoned.offsetNanoseconds / 1_000_000,
272
+ };
273
+ }
274
+ /**
275
+ * The instant whose wall-clock reading is the civil millisecond `civil`
276
+ * (a UTC-encoded wall time). Candidate offsets are the ones in force just
277
+ * before and just after the wall time; a candidate is valid when the
278
+ * instant it implies actually carries that offset.
279
+ */
280
+ #instantForCivil(civil, disambiguation) {
281
+ const before = this.#segmentAt(civil - CANDIDATE_WINDOW_MS);
282
+ const after = this.#segmentAt(civil + CANDIDATE_WINDOW_MS);
283
+ const tBefore = civil - before.offsetMs;
284
+ const validBefore = this.#segmentAt(tBefore).offsetMs === before.offsetMs;
285
+ if (before === after || before.offsetMs === after.offsetMs) {
286
+ if (validBefore)
287
+ return tBefore;
288
+ // Same offset either side but a transition pair sits in between
289
+ // (a brief change and back) — fall through to the general search.
290
+ }
291
+ const tAfter = civil - after.offsetMs;
292
+ const validAfter = this.#segmentAt(tAfter).offsetMs === after.offsetMs;
293
+ if (validBefore && validAfter) {
294
+ if (tBefore === tAfter)
295
+ return tBefore;
296
+ // The wall time repeats: `tBefore` is the earlier reading.
297
+ switch (disambiguation) {
298
+ case 'reject':
299
+ throw new RangeError(`wall-clock time ${_a.#civilString(civil)} is ambiguous in ${this.id}`);
300
+ case 'later':
301
+ return Math.max(tBefore, tAfter);
302
+ default:
303
+ return Math.min(tBefore, tAfter);
304
+ }
305
+ }
306
+ if (validBefore)
307
+ return tBefore;
308
+ if (validAfter)
309
+ return tAfter;
310
+ // The wall time is skipped. Temporal's 'compatible' moves forward past
311
+ // the gap: keep the offset that was in force before it, which lands
312
+ // the same distance after the transition as the wall time was after
313
+ // the old clock's last reading.
314
+ switch (disambiguation) {
315
+ case 'reject':
316
+ throw new RangeError(`wall-clock time ${_a.#civilString(civil)} does not exist in ${this.id}`);
317
+ case 'earlier':
318
+ return civil - after.offsetMs;
319
+ default:
320
+ return civil - before.offsetMs;
321
+ }
322
+ }
323
+ static #civilMs(parts) {
324
+ const { year, month, day } = parts;
325
+ const hour = parts.hour ?? 0;
326
+ const minute = parts.minute ?? 0;
327
+ const second = parts.second ?? 0;
328
+ const millisecond = parts.millisecond ?? 0;
329
+ const inRange = (v, lo, hi) => Number.isInteger(v) && v >= lo && v <= hi;
330
+ if (!Number.isInteger(year) ||
331
+ !inRange(month, 1, 12) ||
332
+ !inRange(day, 1, daysInMonth(year, month)) ||
333
+ !inRange(hour, 0, 23) ||
334
+ !inRange(minute, 0, 59) ||
335
+ !inRange(second, 0, 59) ||
336
+ !inRange(millisecond, 0, 999)) {
337
+ // Reject rather than roll over: `{ month: 2, day: 30 }` is a caller
338
+ // error, not February 30th, and silently landing on March 2nd is how
339
+ // a bucket edge ends up in the wrong month.
340
+ throw new RangeError(`invalid wall-clock time ${JSON.stringify(parts)}`);
341
+ }
342
+ return (daysFromCivil(year, month, day) * MS_PER_DAY +
343
+ hour * MS_PER_HOUR +
344
+ minute * 60_000 +
345
+ second * 1_000 +
346
+ millisecond);
347
+ }
348
+ static #civilParts(civil) {
349
+ const days = Math.floor(civil / MS_PER_DAY);
350
+ let rem = civil - days * MS_PER_DAY;
351
+ const [year, month, day] = civilFromDays(days);
352
+ const hour = Math.floor(rem / MS_PER_HOUR);
353
+ rem -= hour * MS_PER_HOUR;
354
+ const minute = Math.floor(rem / 60_000);
355
+ rem -= minute * 60_000;
356
+ const second = Math.floor(rem / 1_000);
357
+ const millisecond = rem - second * 1_000;
358
+ return {
359
+ year,
360
+ month,
361
+ day,
362
+ hour,
363
+ minute,
364
+ second,
365
+ millisecond,
366
+ weekday: weekdayFromDays(days),
367
+ };
368
+ }
369
+ static #civilString(civil) {
370
+ return new Date(civil).toISOString().replace('Z', '');
371
+ }
372
+ /** Day number of the first day of the `unit` containing day number `days`. */
373
+ static #startDays(unit, days, weekStartsOn) {
374
+ switch (unit) {
375
+ case 'day':
376
+ return days;
377
+ case 'week':
378
+ return days - ((weekdayFromDays(days) - weekStartsOn + 7) % 7);
379
+ case 'month': {
380
+ const [y, m] = civilFromDays(days);
381
+ return daysFromCivil(y, m, 1);
382
+ }
383
+ case 'quarter': {
384
+ const [y, m] = civilFromDays(days);
385
+ return daysFromCivil(y, m - ((m - 1) % 3), 1);
386
+ }
387
+ case 'year': {
388
+ const [y] = civilFromDays(days);
389
+ return daysFromCivil(y, 1, 1);
390
+ }
391
+ }
392
+ }
393
+ /** Day number of the start of the following `unit`, given a unit-start day number. */
394
+ static #advanceDays(unit, startDays) {
395
+ switch (unit) {
396
+ case 'day':
397
+ return startDays + 1;
398
+ case 'week':
399
+ return startDays + 7;
400
+ case 'month':
401
+ case 'quarter':
402
+ case 'year': {
403
+ const [y, m] = civilFromDays(startDays);
404
+ const months = unit === 'month' ? 1 : unit === 'quarter' ? 3 : 12;
405
+ const total = y * 12 + (m - 1) + months;
406
+ return daysFromCivil(Math.floor(total / 12), (total % 12) + 1, 1);
407
+ }
408
+ }
409
+ }
410
+ }
411
+ _a = TimeZone;
412
+ export { assertCalendarUnit };
413
+ //# sourceMappingURL=time-zone.js.map
package/dist/index.d.ts CHANGED
@@ -35,6 +35,8 @@ export { type IntervalLabelKind, type KeyColumn, IntervalKeyColumn, TimeKeyColum
35
35
  export { type ValidityBitmap } from './columnar/validity.js';
36
36
  export type { AlignSchema, ArrayColumnNameForSchema, BaselineSchema, AggregateFunction, AggregateReducer, AggregateOutputMap, AggregateOutputSpec, AggregateMap, AggregateSchema, ValidatedAggregateMap, ColumnDef, CollapseData, ArrayAggregateAppendSchema, ArrayAggregateKind, ArrayAggregateReplaceSchema, ArrayExplodeAppendSchema, ArrayExplodeReplaceSchema, CollapseSchema, DedupeKeep, EventDataForSchema, EventForSchema, EventKeyForKind, FillMapping, FillStrategy, MaterializeSchema, EventKeyForSchema, FirstColKind, FirstColumn, IntervalKeyedSchema, JsonIntervalInput, JsonObjectRowForSchema, JsonRowFormat, JsonRowForSchema, JsonTimeRangeInput, JsonTimestampInput, JsonValueForKind, RollingAlignment, RollingSchema, JoinConflictMode, JoinManySchema, PrefixedJoinManySchema, PrefixedJoinSchema, LiveSource, JoinType, JoinSchema, NormalizedRowForSchema, NormalizedObjectRowForSchema, NormalizedObjectRow, NormalizedValueForKind, ReduceResult, RenameData, RenameMap, RenameSchema, RekeySchema, RowForSchema, AppendColumn, OptionalNumberColumn, ArrayValue, ColumnValue, ScalarKind, ScalarValue, CustomAggregateReducer, DiffSchema, NumericColumnNameForSchema, SmoothMethod, SmoothAppendSchema, SmoothSchema, SelectData, SelectSchema, SeriesSchema, TimeKeyedSchema, FlatKeyColumns, TimeSeriesColumnarInput, TimeSeriesColumnarOutput, TimeSeriesInput, TimeSeriesJsonColumns, TimeSeriesJsonInput, TimeRangeKeyedSchema, ValueColumnsForSchema, ValueColumn, ValueForKind, ValueFirstColumn, ValueKeyedSchema, ValueSeriesColumnarInput, ValueSeriesColumnarOutput, ValueSeriesColumnName, ValueSeriesJsonCell, ValueSeriesJsonColumns, ValueSeriesJsonInput, ValueSeriesJsonObjectRow, ValueSeriesJsonOutputArray, ValueSeriesJsonOutputObject, ValueSeriesJsonRow, ValueSeriesObjectRow, ValueSeriesRow, ValueSeriesSchema, ValueSeriesValueColumns, } from './schema/index.js';
37
37
  export type { CalendarOptions, CalendarUnit, TimeZoneOptions, } from './core/calendar.js';
38
+ export { TimeZone } from './core/time-zone.js';
39
+ export type { Disambiguation, StartOfOptions, ZonedParts, ZonedPartsInput, } from './core/time-zone.js';
38
40
  export type { EventKey, IntervalInput, IntervalValue, TemporalLike, TimeRangeInput, TimestampInput, } from './core/temporal.js';
39
41
  export type { DurationInput } from './core/duration.js';
40
42
  export type { SequenceSample } from './sequence/sequence.js';
package/dist/index.js CHANGED
@@ -55,4 +55,5 @@ export { StringColumn } from './columnar/string-column.js';
55
55
  export { ArrayColumn } from './columnar/array-column.js';
56
56
  export { ChunkedArrayColumn, ChunkedBooleanColumn, ChunkedFloat64Column, ChunkedStringColumn, } from './columnar/chunked-column.js';
57
57
  export { IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, ValueKeyColumn, } from './columnar/key-column.js';
58
+ export { TimeZone } from './core/time-zone.js';
58
59
  //# sourceMappingURL=index.js.map
@@ -66,7 +66,10 @@ export declare class Sequence {
66
66
  * Creates an unbounded calendar-aware sequence.
67
67
  *
68
68
  * Calendar sequences step by local calendar boundaries in an IANA time zone instead of by a
69
- * fixed millisecond duration. Supported units are `"day"`, `"week"`, and `"month"`.
69
+ * fixed millisecond duration. Supported units are `"day"`, `"week"`, `"month"`, `"quarter"`
70
+ * and `"year"`; any other unit throws `RangeError`. Boundaries come from `TimeZone`, the
71
+ * same primitive the charts' time axis places ticks with, so a bucket edge and the tick
72
+ * that labels it are one instant.
70
73
  *
71
74
  * Defaults:
72
75
  * - `timeZone`: `"UTC"`
@@ -1,5 +1,6 @@
1
1
  import { BoundedSequence } from './bounded-sequence.js';
2
- import { normalizeWeekStartsOn, nextCalendarStart, plainDateToStart, resolveTimeZone, toPlainDateStart, } from '../core/calendar.js';
2
+ import { normalizeWeekStartsOn, resolveTimeZone, } from '../core/calendar.js';
3
+ import { TimeZone, assertCalendarUnit } from '../core/time-zone.js';
3
4
  import { parseDuration } from '../core/duration.js';
4
5
  import { Interval } from '../core/interval.js';
5
6
  import { toTimeRange } from '../core/time-range.js';
@@ -31,8 +32,10 @@ export class Sequence {
31
32
  }
32
33
  else {
33
34
  this.#kind = 'calendar';
35
+ assertCalendarUnit(input.unit);
34
36
  this.#calendarUnit = input.unit;
35
- this.#timeZone = input.timeZone;
37
+ // Resolve eagerly so an unknown zone fails here, not on first `bounded()`.
38
+ this.#timeZone = TimeZone.of(input.timeZone).id;
36
39
  this.#weekStartsOn = normalizeWeekStartsOn(input.weekStartsOn);
37
40
  }
38
41
  Object.freeze(this);
@@ -62,7 +65,10 @@ export class Sequence {
62
65
  * Creates an unbounded calendar-aware sequence.
63
66
  *
64
67
  * Calendar sequences step by local calendar boundaries in an IANA time zone instead of by a
65
- * fixed millisecond duration. Supported units are `"day"`, `"week"`, and `"month"`.
68
+ * fixed millisecond duration. Supported units are `"day"`, `"week"`, `"month"`, `"quarter"`
69
+ * and `"year"`; any other unit throws `RangeError`. Boundaries come from `TimeZone`, the
70
+ * same primitive the charts' time axis places ticks with, so a bucket edge and the tick
71
+ * that labels it are one instant.
66
72
  *
67
73
  * Defaults:
68
74
  * - `timeZone`: `"UTC"`
@@ -181,16 +187,12 @@ export class Sequence {
181
187
  }
182
188
  return new BoundedSequence(intervals);
183
189
  }
184
- const timeZone = this.#timeZone;
190
+ const zone = TimeZone.of(this.#timeZone);
185
191
  const unit = this.#calendarUnit;
186
- const weekStartsOn = this.#weekStartsOn;
187
- let currentDate = toPlainDateStart(requested.begin(), timeZone, unit, weekStartsOn);
192
+ const startOptions = { weekStartsOn: this.#weekStartsOn };
193
+ let start = zone.startOf(unit, requested.begin(), startOptions);
188
194
  while (true) {
189
- const currentStart = plainDateToStart(currentDate, timeZone);
190
- const nextDate = nextCalendarStart(currentDate, unit);
191
- const nextStart = plainDateToStart(nextDate, timeZone);
192
- const start = currentStart.epochMilliseconds;
193
- const end = nextStart.epochMilliseconds;
195
+ const end = zone.next(unit, start, startOptions);
194
196
  const sampleTime = sample === 'end'
195
197
  ? end
196
198
  : sample === 'center'
@@ -206,7 +208,7 @@ export class Sequence {
206
208
  // 'end': sample ∈ (requested.begin, requested.end]
207
209
  //
208
210
  // Under 'overlap' every candidate is in: the walk starts at the bucket
209
- // containing `requested.begin()` (`toPlainDateStart` floors to it), and
211
+ // containing `requested.begin()` (`zone.startOf` floors to it), and
210
212
  // every later bucket starts after that, so the leading test can only
211
213
  // ever pass. This is the one line the leading-bucket drop turned on —
212
214
  // the containing bucket was computed above and then discarded here.
@@ -218,7 +220,7 @@ export class Sequence {
218
220
  if (include) {
219
221
  intervals.push(new Interval({ value: start, start, end }));
220
222
  }
221
- currentDate = nextDate;
223
+ start = end;
222
224
  }
223
225
  return new BoundedSequence(intervals);
224
226
  }