@ultimat3/time 1.0.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 +98 -0
- package/package.json +35 -0
- package/src/business.d.ts +37 -0
- package/src/business.d.ts.map +1 -0
- package/src/business.js +68 -0
- package/src/business.js.map +1 -0
- package/src/business.ts +90 -0
- package/src/context.d.ts +40 -0
- package/src/context.d.ts.map +1 -0
- package/src/context.js +61 -0
- package/src/context.js.map +1 -0
- package/src/context.ts +94 -0
- package/src/cron-describe.ts +159 -0
- package/src/cron-occurrence.ts +203 -0
- package/src/cron-parse.ts +193 -0
- package/src/cron.d.ts +60 -0
- package/src/cron.d.ts.map +1 -0
- package/src/cron.js +390 -0
- package/src/cron.js.map +1 -0
- package/src/cron.ts +16 -0
- package/src/duration.d.ts +32 -0
- package/src/duration.d.ts.map +1 -0
- package/src/duration.js +134 -0
- package/src/duration.js.map +1 -0
- package/src/duration.ts +156 -0
- package/src/errors.d.ts +26 -0
- package/src/errors.d.ts.map +1 -0
- package/src/errors.js +78 -0
- package/src/errors.js.map +1 -0
- package/src/errors.ts +121 -0
- package/src/format.d.ts +54 -0
- package/src/format.d.ts.map +1 -0
- package/src/format.js +133 -0
- package/src/format.js.map +1 -0
- package/src/format.ts +175 -0
- package/src/index.d.ts +12 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +12 -0
- package/src/index.js.map +1 -0
- package/src/index.ts +137 -0
- package/src/instant.d.ts +38 -0
- package/src/instant.d.ts.map +1 -0
- package/src/instant.js +76 -0
- package/src/instant.js.map +1 -0
- package/src/instant.ts +94 -0
- package/src/schedule.d.ts +24 -0
- package/src/schedule.d.ts.map +1 -0
- package/src/schedule.js +67 -0
- package/src/schedule.js.map +1 -0
- package/src/schedule.ts +90 -0
- package/src/zoned.d.ts +80 -0
- package/src/zoned.d.ts.map +1 -0
- package/src/zoned.js +146 -0
- package/src/zoned.js.map +1 -0
- package/src/zoned.ts +268 -0
- package/src/zones.d.ts +40 -0
- package/src/zones.d.ts.map +1 -0
- package/src/zones.js +116 -0
- package/src/zones.js.map +1 -0
- package/src/zones.ts +167 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schedule.js","sourceRoot":"","sources":["schedule.ts"],"names":[],"mappings":"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"}
|
package/src/schedule.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Send at 09:00 local" — the scheduling primitive digests, reminders and drip campaigns
|
|
3
|
+
* are built from. Local means the *recipient's* local, and it survives DST.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { scheduleInvalid, timezoneInvalid } from './errors';
|
|
7
|
+
import type { Instant } from './instant';
|
|
8
|
+
import { fromZoned, toZoned } from './zoned';
|
|
9
|
+
import { assertTimeZone, type TimeZone } from './zones';
|
|
10
|
+
|
|
11
|
+
export interface LocalSlot {
|
|
12
|
+
zone: TimeZone;
|
|
13
|
+
/** 0–23, wall clock in `zone`. */
|
|
14
|
+
hour: number;
|
|
15
|
+
/** 0–59. */
|
|
16
|
+
minute?: number;
|
|
17
|
+
second?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Next instant matching the local `HH:mm` in `zone`, strictly after `after`.
|
|
22
|
+
*
|
|
23
|
+
* Walks forward day by day on the *local* calendar rather than adding 86 400 000 ms, so
|
|
24
|
+
* a 23- or 25-hour day does not shift the slot. If the slot falls in a spring-forward
|
|
25
|
+
* gap, `{ gap: 'next' }` picks the first existing local time instead of skipping the day.
|
|
26
|
+
*/
|
|
27
|
+
/** Wall-clock fields are never wrapped or clamped — a shifted schedule beats no schedule. */
|
|
28
|
+
function assertWallField(field: string, value: number, max: number): void {
|
|
29
|
+
if (!Number.isInteger(value) || value < 0 || value > max) {
|
|
30
|
+
throw scheduleInvalid(field, value, `an integer 0-${String(max)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function nextLocalSlot(slot: LocalSlot, after: Instant): Instant {
|
|
35
|
+
assertTimeZone(slot.zone);
|
|
36
|
+
assertWallField('slot.hour', slot.hour, 23);
|
|
37
|
+
const minute = slot.minute ?? 0;
|
|
38
|
+
const second = slot.second ?? 0;
|
|
39
|
+
// Validated too: a minute of 90 would otherwise roll into the next hour and ship a
|
|
40
|
+
// schedule an hour off, which is exactly the class of bug this package exists to prevent.
|
|
41
|
+
assertWallField('slot.minute', minute, 59);
|
|
42
|
+
assertWallField('slot.second', second, 59);
|
|
43
|
+
const start = toZoned(after, slot.zone);
|
|
44
|
+
|
|
45
|
+
for (let dayOffset = 0; dayOffset < 4; dayOffset += 1) {
|
|
46
|
+
const candidate = fromZoned(
|
|
47
|
+
{
|
|
48
|
+
year: start.year,
|
|
49
|
+
month: start.month,
|
|
50
|
+
day: start.day + dayOffset,
|
|
51
|
+
hour: slot.hour,
|
|
52
|
+
minute,
|
|
53
|
+
second,
|
|
54
|
+
},
|
|
55
|
+
slot.zone,
|
|
56
|
+
{ gap: 'next', overlap: 'first' },
|
|
57
|
+
);
|
|
58
|
+
if (candidate.getTime() > after.getTime()) return candidate;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Four local days always contain the slot; reaching here means the zone data is broken.
|
|
62
|
+
throw timezoneInvalid(slot.zone);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The next `count` daily slots — a preview for the schedule screen. */
|
|
66
|
+
export function nextLocalSlots(slot: LocalSlot, after: Instant, count: number): Instant[] {
|
|
67
|
+
const slots: Instant[] = [];
|
|
68
|
+
let cursor = after;
|
|
69
|
+
for (let index = 0; index < count; index += 1) {
|
|
70
|
+
cursor = nextLocalSlot(slot, cursor);
|
|
71
|
+
slots.push(cursor);
|
|
72
|
+
}
|
|
73
|
+
return slots;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface WeeklySlot extends LocalSlot {
|
|
77
|
+
/** ISO weekday: 1 = Monday … 7 = Sunday. */
|
|
78
|
+
weekday: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Next `weekday` at the local time, strictly after `after`. */
|
|
82
|
+
export function nextWeeklySlot(slot: WeeklySlot, after: Instant): Instant {
|
|
83
|
+
let cursor = after;
|
|
84
|
+
for (let index = 0; index < 8; index += 1) {
|
|
85
|
+
const candidate = nextLocalSlot(slot, cursor);
|
|
86
|
+
if (toZoned(candidate, slot.zone).weekday === slot.weekday) return candidate;
|
|
87
|
+
cursor = candidate;
|
|
88
|
+
}
|
|
89
|
+
throw timezoneInvalid(slot.zone);
|
|
90
|
+
}
|
package/src/zoned.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two conversions everything else is built on: instant → wall clock, and wall clock →
|
|
3
|
+
* instant. `fromZoned` is the DST-correct one, and it is the reason this package exists.
|
|
4
|
+
*/
|
|
5
|
+
import { type Instant } from './instant';
|
|
6
|
+
import { type TimeZone } from './zones';
|
|
7
|
+
/** A local date and time with no zone attached — meaningless until paired with one. */
|
|
8
|
+
export interface WallClock {
|
|
9
|
+
year: number;
|
|
10
|
+
month: number;
|
|
11
|
+
day: number;
|
|
12
|
+
hour: number;
|
|
13
|
+
minute: number;
|
|
14
|
+
second?: number;
|
|
15
|
+
millisecond?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ZonedDateTime {
|
|
18
|
+
year: number;
|
|
19
|
+
month: number;
|
|
20
|
+
day: number;
|
|
21
|
+
hour: number;
|
|
22
|
+
minute: number;
|
|
23
|
+
second: number;
|
|
24
|
+
millisecond: number;
|
|
25
|
+
zone: TimeZone;
|
|
26
|
+
/** Minutes east of UTC in effect at this instant. */
|
|
27
|
+
offsetMinutes: number;
|
|
28
|
+
/** ISO weekday: 1 = Monday … 7 = Sunday. */
|
|
29
|
+
weekday: number;
|
|
30
|
+
}
|
|
31
|
+
/** What to do with a wall-clock time that does not exist (spring forward). */
|
|
32
|
+
export type GapPolicy = 'next' | 'previous' | 'throw';
|
|
33
|
+
/** What to do with a wall-clock time that happens twice (fall back). */
|
|
34
|
+
export type OverlapPolicy = 'first' | 'second' | 'throw';
|
|
35
|
+
export interface FromZonedOptions {
|
|
36
|
+
gap?: GapPolicy;
|
|
37
|
+
overlap?: OverlapPolicy;
|
|
38
|
+
}
|
|
39
|
+
export type ZonedResolution = 'exact' | 'gap' | 'overlap';
|
|
40
|
+
export interface FromZonedResult {
|
|
41
|
+
instant: Instant;
|
|
42
|
+
/** Which case the wall-clock time fell into — log it when scheduling. */
|
|
43
|
+
resolution: ZonedResolution;
|
|
44
|
+
offsetMinutes: number;
|
|
45
|
+
}
|
|
46
|
+
/** Instant → the zone's wall clock. */
|
|
47
|
+
export declare function toZoned(at: Instant, zone: TimeZone): ZonedDateTime;
|
|
48
|
+
/**
|
|
49
|
+
* Wall clock + zone → instant, DST-correct.
|
|
50
|
+
*
|
|
51
|
+
* Algorithm (search and verify — never `offset * 3600`):
|
|
52
|
+
* 1. Read the wall-clock fields as if they were UTC. Call that `target`.
|
|
53
|
+
* 2. Collect the candidate offsets in force around `target` — 24h before, at, and 24h
|
|
54
|
+
* after. Any DST transition puts both of its offsets in that set.
|
|
55
|
+
* 3. For each distinct offset `o`, the candidate instant is `target - o`.
|
|
56
|
+
* 4. Verify each candidate by converting it *back* with `toZoned`. A candidate that does
|
|
57
|
+
* not reproduce the requested wall clock is not a real instant for it.
|
|
58
|
+
* - two distinct verified candidates → the hour repeats (overlap)
|
|
59
|
+
* - exactly one → normal case
|
|
60
|
+
* - none → the hour was skipped (gap)
|
|
61
|
+
*
|
|
62
|
+
* Milliseconds ride along untouched: every real offset is a whole number of minutes.
|
|
63
|
+
*/
|
|
64
|
+
export declare function fromZonedDetailed(wall: WallClock, zone: TimeZone, options?: FromZonedOptions): FromZonedResult;
|
|
65
|
+
/** The common form: just the instant. */
|
|
66
|
+
export declare function fromZoned(wall: WallClock, zone: TimeZone, options?: FromZonedOptions): Instant;
|
|
67
|
+
/** Midnight local, as an instant. DST-correct: on a spring-forward day it still exists. */
|
|
68
|
+
export declare function startOfDay(at: Instant, zone: TimeZone): Instant;
|
|
69
|
+
/** The last millisecond of the local day. */
|
|
70
|
+
export declare function endOfDay(at: Instant, zone: TimeZone): Instant;
|
|
71
|
+
/**
|
|
72
|
+
* Add calendar days in a zone, keeping the wall-clock time. Adding one day is 23, 24 or
|
|
73
|
+
* 25 hours depending on the transition — which is why this is not `+ 86_400_000`.
|
|
74
|
+
*/
|
|
75
|
+
export declare function addDaysInZone(at: Instant, days: number, zone: TimeZone): Instant;
|
|
76
|
+
/** `2026-03-14` for the given instant in the given zone. */
|
|
77
|
+
export declare function isoDateInZone(at: Instant, zone: TimeZone): string;
|
|
78
|
+
/** True when both instants fall on the same local calendar day. */
|
|
79
|
+
export declare function isSameLocalDay(left: Instant, right: Instant, zone: TimeZone): boolean;
|
|
80
|
+
//# sourceMappingURL=zoned.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zoned.d.ts","sourceRoot":"","sources":["zoned.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAS,KAAK,OAAO,EAAE,MAAM,WAAW,CAAC;AAChD,OAAO,EAA4B,KAAK,QAAQ,EAAe,MAAM,SAAS,CAAC;AAE/E,uFAAuF;AACvF,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,QAAQ,CAAC;IACf,qDAAqD;IACrD,aAAa,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,8EAA8E;AAC9E,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC;AACtD,wEAAwE;AACxE,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEzD,MAAM,WAAW,gBAAgB;IAC/B,GAAG,CAAC,EAAE,SAAS,CAAC;IAChB,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,KAAK,GAAG,SAAS,CAAC;AAE1D,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,yEAAyE;IACzE,UAAU,EAAE,eAAe,CAAC;IAC5B,aAAa,EAAE,MAAM,CAAC;CACvB;AAID,uCAAuC;AACvC,wBAAgB,OAAO,CAAC,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,GAAG,aAAa,CAalE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,SAAS,EACf,IAAI,EAAE,QAAQ,EACd,OAAO,GAAE,gBAAqB,GAC7B,eAAe,CA2CjB;AAED,yCAAyC;AACzC,wBAAgB,SAAS,CACvB,IAAI,EAAE,SAAS,EACf,IAAI,EAAE,QAAQ,EACd,OAAO,GAAE,gBAAqB,GAC7B,OAAO,CAET;AAED,2FAA2F;AAC3F,wBAAgB,UAAU,CAAC,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,GAAG,OAAO,CAO/D;AAED,6CAA6C;AAC7C,wBAAgB,QAAQ,CAAC,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,GAAG,OAAO,CAQ7D;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,OAAO,CAehF;AAED,4DAA4D;AAC5D,wBAAgB,aAAa,CAAC,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,GAAG,MAAM,CAGjE;AAED,mEAAmE;AACnE,wBAAgB,cAAc,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,GAAG,OAAO,CAErF"}
|
package/src/zoned.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two conversions everything else is built on: instant → wall clock, and wall clock →
|
|
3
|
+
* instant. `fromZoned` is the DST-correct one, and it is the reason this package exists.
|
|
4
|
+
*/
|
|
5
|
+
import { dstAmbiguous, dstNonexistent } from './errors';
|
|
6
|
+
import { addMs } from './instant';
|
|
7
|
+
import { assertTimeZone, offsetAt, zonePartsAt } from './zones';
|
|
8
|
+
const DEFAULTS = { gap: 'next', overlap: 'first' };
|
|
9
|
+
/** Instant → the zone's wall clock. */
|
|
10
|
+
export function toZoned(at, zone) {
|
|
11
|
+
assertTimeZone(zone);
|
|
12
|
+
const parts = zonePartsAt(zone, at);
|
|
13
|
+
const offsetMinutes = offsetAt(zone, at);
|
|
14
|
+
const asIfUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute);
|
|
15
|
+
return {
|
|
16
|
+
...parts,
|
|
17
|
+
millisecond: at.getTime() - Math.floor(at.getTime() / 1000) * 1000,
|
|
18
|
+
zone,
|
|
19
|
+
offsetMinutes,
|
|
20
|
+
// getUTCDay on the as-if-UTC epoch gives the *local* weekday: 0 = Sunday.
|
|
21
|
+
weekday: ((new Date(asIfUtc).getUTCDay() + 6) % 7) + 1,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Wall clock + zone → instant, DST-correct.
|
|
26
|
+
*
|
|
27
|
+
* Algorithm (search and verify — never `offset * 3600`):
|
|
28
|
+
* 1. Read the wall-clock fields as if they were UTC. Call that `target`.
|
|
29
|
+
* 2. Collect the candidate offsets in force around `target` — 24h before, at, and 24h
|
|
30
|
+
* after. Any DST transition puts both of its offsets in that set.
|
|
31
|
+
* 3. For each distinct offset `o`, the candidate instant is `target - o`.
|
|
32
|
+
* 4. Verify each candidate by converting it *back* with `toZoned`. A candidate that does
|
|
33
|
+
* not reproduce the requested wall clock is not a real instant for it.
|
|
34
|
+
* - two distinct verified candidates → the hour repeats (overlap)
|
|
35
|
+
* - exactly one → normal case
|
|
36
|
+
* - none → the hour was skipped (gap)
|
|
37
|
+
*
|
|
38
|
+
* Milliseconds ride along untouched: every real offset is a whole number of minutes.
|
|
39
|
+
*/
|
|
40
|
+
export function fromZonedDetailed(wall, zone, options = {}) {
|
|
41
|
+
assertTimeZone(zone);
|
|
42
|
+
const { gap, overlap } = { ...DEFAULTS, ...options };
|
|
43
|
+
const millisecond = wall.millisecond ?? 0;
|
|
44
|
+
// Normalize overflow first (day 32, hour 24, month 13) so callers can do naive calendar
|
|
45
|
+
// arithmetic — `addDaysInZone` relies on it — and so verification compares real fields.
|
|
46
|
+
const normal = normalizeWall(wall);
|
|
47
|
+
const target = Date.UTC(normal.year, normal.month - 1, normal.day, normal.hour, normal.minute, normal.second);
|
|
48
|
+
const probes = [target - 86_400_000, target, target + 86_400_000];
|
|
49
|
+
const offsets = new Set(probes.map((ms) => offsetAt(zone, new Date(ms))));
|
|
50
|
+
const verified = [];
|
|
51
|
+
for (const offsetMinutes of offsets) {
|
|
52
|
+
const candidate = target - offsetMinutes * 60_000;
|
|
53
|
+
if (matchesWall(candidate, zone, normal))
|
|
54
|
+
verified.push(candidate);
|
|
55
|
+
}
|
|
56
|
+
const distinct = [...new Set(verified)].sort((a, b) => a - b);
|
|
57
|
+
if (distinct.length > 1) {
|
|
58
|
+
if (overlap === 'throw')
|
|
59
|
+
throw dstAmbiguous(describeWall(normal), zone);
|
|
60
|
+
const chosen = overlap === 'first' ? distinct[0] : distinct[distinct.length - 1];
|
|
61
|
+
return finish(chosen ?? target, millisecond, zone, 'overlap');
|
|
62
|
+
}
|
|
63
|
+
const single = distinct[0];
|
|
64
|
+
if (single !== undefined)
|
|
65
|
+
return finish(single, millisecond, zone, 'exact');
|
|
66
|
+
// Gap: the requested wall clock was skipped. The two unverified candidates bracket it —
|
|
67
|
+
// the smaller lands before the transition, the larger after it.
|
|
68
|
+
if (gap === 'throw')
|
|
69
|
+
throw dstNonexistent(describeWall(normal), zone);
|
|
70
|
+
const bracket = [...offsets]
|
|
71
|
+
.map((offsetMinutes) => target - offsetMinutes * 60_000)
|
|
72
|
+
.sort((a, b) => a - b);
|
|
73
|
+
const chosen = gap === 'next' ? bracket[bracket.length - 1] : bracket[0];
|
|
74
|
+
return finish(chosen ?? target, millisecond, zone, 'gap');
|
|
75
|
+
}
|
|
76
|
+
/** The common form: just the instant. */
|
|
77
|
+
export function fromZoned(wall, zone, options = {}) {
|
|
78
|
+
return fromZonedDetailed(wall, zone, options).instant;
|
|
79
|
+
}
|
|
80
|
+
/** Midnight local, as an instant. DST-correct: on a spring-forward day it still exists. */
|
|
81
|
+
export function startOfDay(at, zone) {
|
|
82
|
+
const zoned = toZoned(at, zone);
|
|
83
|
+
return fromZoned({ year: zoned.year, month: zoned.month, day: zoned.day, hour: 0, minute: 0 }, zone, { gap: 'next' });
|
|
84
|
+
}
|
|
85
|
+
/** The last millisecond of the local day. */
|
|
86
|
+
export function endOfDay(at, zone) {
|
|
87
|
+
const zoned = toZoned(at, zone);
|
|
88
|
+
const nextMidnight = fromZoned({ year: zoned.year, month: zoned.month, day: zoned.day + 1, hour: 0, minute: 0 }, zone, { gap: 'next' });
|
|
89
|
+
return addMs(nextMidnight, -1);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Add calendar days in a zone, keeping the wall-clock time. Adding one day is 23, 24 or
|
|
93
|
+
* 25 hours depending on the transition — which is why this is not `+ 86_400_000`.
|
|
94
|
+
*/
|
|
95
|
+
export function addDaysInZone(at, days, zone) {
|
|
96
|
+
const zoned = toZoned(at, zone);
|
|
97
|
+
return fromZoned({
|
|
98
|
+
year: zoned.year,
|
|
99
|
+
month: zoned.month,
|
|
100
|
+
day: zoned.day + days,
|
|
101
|
+
hour: zoned.hour,
|
|
102
|
+
minute: zoned.minute,
|
|
103
|
+
second: zoned.second,
|
|
104
|
+
millisecond: zoned.millisecond,
|
|
105
|
+
}, zone, { gap: 'next' });
|
|
106
|
+
}
|
|
107
|
+
/** `2026-03-14` for the given instant in the given zone. */
|
|
108
|
+
export function isoDateInZone(at, zone) {
|
|
109
|
+
const zoned = toZoned(at, zone);
|
|
110
|
+
return `${String(zoned.year).padStart(4, '0')}-${pad2(zoned.month)}-${pad2(zoned.day)}`;
|
|
111
|
+
}
|
|
112
|
+
/** True when both instants fall on the same local calendar day. */
|
|
113
|
+
export function isSameLocalDay(left, right, zone) {
|
|
114
|
+
return isoDateInZone(left, zone) === isoDateInZone(right, zone);
|
|
115
|
+
}
|
|
116
|
+
function finish(epochMs, millisecond, zone, resolution) {
|
|
117
|
+
const value = new Date(epochMs + millisecond);
|
|
118
|
+
return { instant: value, resolution, offsetMinutes: offsetAt(zone, value) };
|
|
119
|
+
}
|
|
120
|
+
function normalizeWall(wall) {
|
|
121
|
+
const asUtc = new Date(Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second ?? 0));
|
|
122
|
+
return {
|
|
123
|
+
year: asUtc.getUTCFullYear(),
|
|
124
|
+
month: asUtc.getUTCMonth() + 1,
|
|
125
|
+
day: asUtc.getUTCDate(),
|
|
126
|
+
hour: asUtc.getUTCHours(),
|
|
127
|
+
minute: asUtc.getUTCMinutes(),
|
|
128
|
+
second: asUtc.getUTCSeconds(),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function matchesWall(epochMs, zone, wall) {
|
|
132
|
+
const parts = zonePartsAt(zone, new Date(epochMs));
|
|
133
|
+
return (parts.year === wall.year &&
|
|
134
|
+
parts.month === wall.month &&
|
|
135
|
+
parts.day === wall.day &&
|
|
136
|
+
parts.hour === wall.hour &&
|
|
137
|
+
parts.minute === wall.minute &&
|
|
138
|
+
parts.second === wall.second);
|
|
139
|
+
}
|
|
140
|
+
function describeWall(wall) {
|
|
141
|
+
return `${String(wall.year).padStart(4, '0')}-${pad2(wall.month)}-${pad2(wall.day)} ${pad2(wall.hour)}:${pad2(wall.minute)}:${pad2(wall.second)}`;
|
|
142
|
+
}
|
|
143
|
+
function pad2(value) {
|
|
144
|
+
return String(value).padStart(2, '0');
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=zoned.js.map
|
package/src/zoned.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zoned.js","sourceRoot":"","sources":["zoned.ts"],"names":[],"mappings":"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"}
|
package/src/zoned.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two conversions everything else is built on: instant → wall clock, and wall clock →
|
|
3
|
+
* instant. `fromZoned` is the DST-correct one, and it is the reason this package exists.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { dstAmbiguous, dstNonexistent } from './errors';
|
|
7
|
+
import { addMs, type Instant } from './instant';
|
|
8
|
+
import { assertTimeZone, offsetAt, type TimeZone, utcEpoch, zonePartsAt } from './zones';
|
|
9
|
+
|
|
10
|
+
/** A local date and time with no zone attached — meaningless until paired with one. */
|
|
11
|
+
export interface WallClock {
|
|
12
|
+
year: number;
|
|
13
|
+
month: number;
|
|
14
|
+
day: number;
|
|
15
|
+
hour: number;
|
|
16
|
+
minute: number;
|
|
17
|
+
second?: number;
|
|
18
|
+
millisecond?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ZonedDateTime {
|
|
22
|
+
year: number;
|
|
23
|
+
month: number;
|
|
24
|
+
day: number;
|
|
25
|
+
hour: number;
|
|
26
|
+
minute: number;
|
|
27
|
+
second: number;
|
|
28
|
+
millisecond: number;
|
|
29
|
+
zone: TimeZone;
|
|
30
|
+
/** Minutes east of UTC in effect at this instant. */
|
|
31
|
+
offsetMinutes: number;
|
|
32
|
+
/** ISO weekday: 1 = Monday … 7 = Sunday. */
|
|
33
|
+
weekday: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What to do with a wall-clock time that does not exist (spring forward). */
|
|
37
|
+
export type GapPolicy = 'next' | 'previous' | 'throw';
|
|
38
|
+
/** What to do with a wall-clock time that happens twice (fall back). */
|
|
39
|
+
export type OverlapPolicy = 'first' | 'second' | 'throw';
|
|
40
|
+
|
|
41
|
+
export interface FromZonedOptions {
|
|
42
|
+
gap?: GapPolicy;
|
|
43
|
+
overlap?: OverlapPolicy;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ZonedResolution = 'exact' | 'gap' | 'overlap';
|
|
47
|
+
|
|
48
|
+
export interface FromZonedResult {
|
|
49
|
+
instant: Instant;
|
|
50
|
+
/** Which case the wall-clock time fell into — log it when scheduling. */
|
|
51
|
+
resolution: ZonedResolution;
|
|
52
|
+
offsetMinutes: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEFAULTS: Required<FromZonedOptions> = { gap: 'next', overlap: 'first' };
|
|
56
|
+
|
|
57
|
+
/** Instant → the zone's wall clock. */
|
|
58
|
+
export function toZoned(at: Instant, zone: TimeZone): ZonedDateTime {
|
|
59
|
+
assertTimeZone(zone);
|
|
60
|
+
const parts = zonePartsAt(zone, at);
|
|
61
|
+
const offsetMinutes = offsetAt(zone, at);
|
|
62
|
+
const asIfUtc = utcEpoch(parts.year, parts.month, parts.day);
|
|
63
|
+
return {
|
|
64
|
+
...parts,
|
|
65
|
+
millisecond: at.getTime() - Math.floor(at.getTime() / 1000) * 1000,
|
|
66
|
+
zone,
|
|
67
|
+
offsetMinutes,
|
|
68
|
+
// getUTCDay on the as-if-UTC epoch gives the *local* weekday: 0 = Sunday.
|
|
69
|
+
weekday: ((new Date(asIfUtc).getUTCDay() + 6) % 7) + 1,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Wall clock + zone → instant, DST-correct.
|
|
75
|
+
*
|
|
76
|
+
* Algorithm (search and verify — never `offset * 3600`):
|
|
77
|
+
* 1. Read the wall-clock fields as if they were UTC. Call that `target`.
|
|
78
|
+
* 2. Collect the candidate offsets in force around `target` — 24h before, at, and 24h
|
|
79
|
+
* after. Any DST transition puts both of its offsets in that set.
|
|
80
|
+
* 3. For each distinct offset `o`, the candidate instant is `target - o`.
|
|
81
|
+
* 4. Verify each candidate by converting it *back* with `toZoned`. A candidate that does
|
|
82
|
+
* not reproduce the requested wall clock is not a real instant for it.
|
|
83
|
+
* - two distinct verified candidates → the hour repeats (overlap)
|
|
84
|
+
* - exactly one → normal case
|
|
85
|
+
* - none → the hour was skipped (gap)
|
|
86
|
+
*
|
|
87
|
+
* Milliseconds ride along untouched: every real offset is a whole number of minutes.
|
|
88
|
+
*/
|
|
89
|
+
export function fromZonedDetailed(
|
|
90
|
+
wall: WallClock,
|
|
91
|
+
zone: TimeZone,
|
|
92
|
+
options: FromZonedOptions = {},
|
|
93
|
+
): FromZonedResult {
|
|
94
|
+
assertTimeZone(zone);
|
|
95
|
+
const { gap, overlap } = { ...DEFAULTS, ...options };
|
|
96
|
+
const millisecond = wall.millisecond ?? 0;
|
|
97
|
+
// Normalize overflow first (day 32, hour 24, month 13) so callers can do naive calendar
|
|
98
|
+
// arithmetic — `addDaysInZone` relies on it — and so verification compares real fields.
|
|
99
|
+
const normal = normalizeWall(wall);
|
|
100
|
+
const target = utcEpoch(
|
|
101
|
+
normal.year,
|
|
102
|
+
normal.month,
|
|
103
|
+
normal.day,
|
|
104
|
+
normal.hour,
|
|
105
|
+
normal.minute,
|
|
106
|
+
normal.second,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const probes = [target - 86_400_000, target, target + 86_400_000];
|
|
110
|
+
const offsets = new Set(probes.map((ms) => offsetAt(zone, new Date(ms) as Instant)));
|
|
111
|
+
|
|
112
|
+
const verified: number[] = [];
|
|
113
|
+
for (const offsetMinutes of offsets) {
|
|
114
|
+
const candidate = target - offsetMinutes * 60_000;
|
|
115
|
+
if (matchesWall(candidate, zone, normal)) verified.push(candidate);
|
|
116
|
+
}
|
|
117
|
+
const distinct = [...new Set(verified)].sort((a, b) => a - b);
|
|
118
|
+
|
|
119
|
+
if (distinct.length > 1) {
|
|
120
|
+
if (overlap === 'throw') throw dstAmbiguous(describeWall(normal), zone);
|
|
121
|
+
const chosen = overlap === 'first' ? distinct[0] : distinct[distinct.length - 1];
|
|
122
|
+
return finish(chosen ?? target, millisecond, zone, 'overlap');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const single = distinct[0];
|
|
126
|
+
if (single !== undefined) return finish(single, millisecond, zone, 'exact');
|
|
127
|
+
|
|
128
|
+
// Gap: the requested wall clock was skipped. The two unverified candidates bracket it —
|
|
129
|
+
// the smaller lands before the transition, the larger after it.
|
|
130
|
+
if (gap === 'throw') throw dstNonexistent(describeWall(normal), zone);
|
|
131
|
+
const bracket = [...offsets]
|
|
132
|
+
.map((offsetMinutes) => target - offsetMinutes * 60_000)
|
|
133
|
+
.sort((a, b) => a - b);
|
|
134
|
+
const chosen = gap === 'next' ? bracket[bracket.length - 1] : bracket[0];
|
|
135
|
+
return finish(chosen ?? target, millisecond, zone, 'gap');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The common form: just the instant. */
|
|
139
|
+
export function fromZoned(
|
|
140
|
+
wall: WallClock,
|
|
141
|
+
zone: TimeZone,
|
|
142
|
+
options: FromZonedOptions = {},
|
|
143
|
+
): Instant {
|
|
144
|
+
return fromZonedDetailed(wall, zone, options).instant;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Midnight local, as an instant. DST-correct: on a spring-forward day it still exists. */
|
|
148
|
+
export function startOfDay(at: Instant, zone: TimeZone): Instant {
|
|
149
|
+
const zoned = toZoned(at, zone);
|
|
150
|
+
return fromZoned(
|
|
151
|
+
{ year: zoned.year, month: zoned.month, day: zoned.day, hour: 0, minute: 0 },
|
|
152
|
+
zone,
|
|
153
|
+
{ gap: 'next' },
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The last millisecond of the local day. */
|
|
158
|
+
export function endOfDay(at: Instant, zone: TimeZone): Instant {
|
|
159
|
+
const zoned = toZoned(at, zone);
|
|
160
|
+
const nextMidnight = fromZoned(
|
|
161
|
+
{ year: zoned.year, month: zoned.month, day: zoned.day + 1, hour: 0, minute: 0 },
|
|
162
|
+
zone,
|
|
163
|
+
{ gap: 'next' },
|
|
164
|
+
);
|
|
165
|
+
return addMs(nextMidnight, -1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Add calendar days in a zone, keeping the wall-clock time. Adding one day is 23, 24 or
|
|
170
|
+
* 25 hours depending on the transition — which is why this is not `+ 86_400_000`.
|
|
171
|
+
*/
|
|
172
|
+
export function addDaysInZone(at: Instant, days: number, zone: TimeZone): Instant {
|
|
173
|
+
const zoned = toZoned(at, zone);
|
|
174
|
+
return fromZoned(
|
|
175
|
+
{
|
|
176
|
+
year: zoned.year,
|
|
177
|
+
month: zoned.month,
|
|
178
|
+
day: zoned.day + days,
|
|
179
|
+
hour: zoned.hour,
|
|
180
|
+
minute: zoned.minute,
|
|
181
|
+
second: zoned.second,
|
|
182
|
+
millisecond: zoned.millisecond,
|
|
183
|
+
},
|
|
184
|
+
zone,
|
|
185
|
+
{ gap: 'next' },
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** `2026-03-14` for the given instant in the given zone. */
|
|
190
|
+
export function isoDateInZone(at: Instant, zone: TimeZone): string {
|
|
191
|
+
const zoned = toZoned(at, zone);
|
|
192
|
+
return `${String(zoned.year).padStart(4, '0')}-${pad2(zoned.month)}-${pad2(zoned.day)}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** True when both instants fall on the same local calendar day. */
|
|
196
|
+
export function isSameLocalDay(left: Instant, right: Instant, zone: TimeZone): boolean {
|
|
197
|
+
return isoDateInZone(left, zone) === isoDateInZone(right, zone);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whole calendar days from `from` to `to`, counted as local day boundaries crossed in `zone`.
|
|
202
|
+
* Signed, and always integral. Not `differenceMs / 86_400_000`: a 23- or 25-hour DST day is
|
|
203
|
+
* still one day, and 24 real hours inside a 25-hour local day is still zero.
|
|
204
|
+
*/
|
|
205
|
+
export function daysBetween(from: Instant, to: Instant, zone: TimeZone): number {
|
|
206
|
+
// Both dates are reduced to a UTC midnight, so the subtraction carries no offset at all.
|
|
207
|
+
return (localDayEpoch(to, zone) - localDayEpoch(from, zone)) / 86_400_000;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The instant's local calendar date, expressed as the UTC midnight of that date. */
|
|
211
|
+
function localDayEpoch(at: Instant, zone: TimeZone): number {
|
|
212
|
+
const zoned = toZoned(at, zone);
|
|
213
|
+
return utcEpoch(zoned.year, zoned.month, zoned.day);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function finish(
|
|
217
|
+
epochMs: number,
|
|
218
|
+
millisecond: number,
|
|
219
|
+
zone: TimeZone,
|
|
220
|
+
resolution: ZonedResolution,
|
|
221
|
+
): FromZonedResult {
|
|
222
|
+
const value = new Date(epochMs + millisecond) as Instant;
|
|
223
|
+
return { instant: value, resolution, offsetMinutes: offsetAt(zone, value) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Field-complete wall clock with all overflow carried into the higher fields. */
|
|
227
|
+
interface NormalWall {
|
|
228
|
+
year: number;
|
|
229
|
+
month: number;
|
|
230
|
+
day: number;
|
|
231
|
+
hour: number;
|
|
232
|
+
minute: number;
|
|
233
|
+
second: number;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function normalizeWall(wall: WallClock): NormalWall {
|
|
237
|
+
const asUtc = new Date(
|
|
238
|
+
utcEpoch(wall.year, wall.month, wall.day, wall.hour, wall.minute, wall.second ?? 0),
|
|
239
|
+
);
|
|
240
|
+
return {
|
|
241
|
+
year: asUtc.getUTCFullYear(),
|
|
242
|
+
month: asUtc.getUTCMonth() + 1,
|
|
243
|
+
day: asUtc.getUTCDate(),
|
|
244
|
+
hour: asUtc.getUTCHours(),
|
|
245
|
+
minute: asUtc.getUTCMinutes(),
|
|
246
|
+
second: asUtc.getUTCSeconds(),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function matchesWall(epochMs: number, zone: TimeZone, wall: NormalWall): boolean {
|
|
251
|
+
const parts = zonePartsAt(zone, new Date(epochMs) as Instant);
|
|
252
|
+
return (
|
|
253
|
+
parts.year === wall.year &&
|
|
254
|
+
parts.month === wall.month &&
|
|
255
|
+
parts.day === wall.day &&
|
|
256
|
+
parts.hour === wall.hour &&
|
|
257
|
+
parts.minute === wall.minute &&
|
|
258
|
+
parts.second === wall.second
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function describeWall(wall: NormalWall): string {
|
|
263
|
+
return `${String(wall.year).padStart(4, '0')}-${pad2(wall.month)}-${pad2(wall.day)} ${pad2(wall.hour)}:${pad2(wall.minute)}:${pad2(wall.second)}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function pad2(value: number): string {
|
|
267
|
+
return String(value).padStart(2, '0');
|
|
268
|
+
}
|
package/src/zones.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IANA timezone primitives. The UTC offset of a zone is derived from
|
|
3
|
+
* `Intl.DateTimeFormat.formatToParts` — the runtime already ships the tzdata, so there
|
|
4
|
+
* is no offset table to keep in sync and no `date-fns-tz` dependency.
|
|
5
|
+
*/
|
|
6
|
+
import type { Instant } from './instant';
|
|
7
|
+
/** An IANA identifier: `Europe/Berlin`, `Asia/Kathmandu`, `UTC`. Never `CET`, never `+01:00`. */
|
|
8
|
+
export type TimeZone = string;
|
|
9
|
+
export declare const UTC: TimeZone;
|
|
10
|
+
export declare function isValidTimeZone(zone: string): boolean;
|
|
11
|
+
export declare function assertTimeZone(zone: string): TimeZone;
|
|
12
|
+
/** Wall-clock fields of an instant in a zone, seconds precision. */
|
|
13
|
+
export interface ZoneParts {
|
|
14
|
+
year: number;
|
|
15
|
+
month: number;
|
|
16
|
+
day: number;
|
|
17
|
+
hour: number;
|
|
18
|
+
minute: number;
|
|
19
|
+
second: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Read the zone's wall clock for an instant. `hourCycle: 'h23'` is essential: without it
|
|
23
|
+
* some locales render midnight as hour 24 and every calculation downstream drifts a day.
|
|
24
|
+
*/
|
|
25
|
+
export declare function zonePartsAt(zone: TimeZone, at: Instant): ZoneParts;
|
|
26
|
+
/**
|
|
27
|
+
* Offset in **minutes east of UTC** at a given instant: `Europe/Berlin` → 60 or 120,
|
|
28
|
+
* `Asia/Kathmandu` → 345, `America/New_York` → -300 or -240.
|
|
29
|
+
*
|
|
30
|
+
* The trick: format the instant in the zone, then re-read those wall-clock fields *as if
|
|
31
|
+
* they were UTC*. The difference between that and the real epoch is the offset.
|
|
32
|
+
*/
|
|
33
|
+
export declare function offsetAt(zone: TimeZone, at: Instant): number;
|
|
34
|
+
/** `+01:00`, `-04:00`, `+05:45`, `Z`. */
|
|
35
|
+
export declare function offsetLabel(minutes: number): string;
|
|
36
|
+
/** Locale-aware zone label: `CET`, `GMT+5:45`, `Central European Standard Time`. */
|
|
37
|
+
export declare function zoneAbbrev(zone: TimeZone, at: Instant, locale?: string, style?: 'short' | 'long' | 'shortOffset' | 'longOffset'): string;
|
|
38
|
+
/** True when the zone observes a different offset at some point in the surrounding year. */
|
|
39
|
+
export declare function observesDst(zone: TimeZone, at: Instant): boolean;
|
|
40
|
+
//# sourceMappingURL=zones.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zones.d.ts","sourceRoot":"","sources":["zones.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,iGAAiG;AACjG,MAAM,MAAM,QAAQ,GAAG,MAAM,CAAC;AAE9B,eAAO,MAAM,GAAG,EAAE,QAAgB,CAAC;AAKnC,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAQrD;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CAGrD;AAED,oEAAoE;AACpE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,GAAG,SAAS,CAelE;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,GAAG,MAAM,CAY5D;AAED,yCAAyC;AACzC,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAMnD;AAED,oFAAoF;AACpF,wBAAgB,UAAU,CACxB,IAAI,EAAE,QAAQ,EACd,EAAE,EAAE,OAAO,EACX,MAAM,SAAU,EAChB,KAAK,GAAE,OAAO,GAAG,MAAM,GAAG,aAAa,GAAG,YAAsB,GAC/D,MAAM,CAQR;AAED,4FAA4F;AAC5F,wBAAgB,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,GAAG,OAAO,CAQhE"}
|