@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
package/src/format.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Intl.DateTimeFormat` at the edge. Every function takes an explicit `zone` and
|
|
3
|
+
* `locale` — there is no ambient default and no `toLocaleString()` without options,
|
|
4
|
+
* because "the server's timezone" is never the answer to "what time is it for the user".
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { differenceMs, type Instant } from './instant';
|
|
8
|
+
import { assertTimeZone, type TimeZone } from './zones';
|
|
9
|
+
|
|
10
|
+
export type DateTimeStyle = 'short' | 'medium' | 'long' | 'full';
|
|
11
|
+
|
|
12
|
+
export interface FormatContext {
|
|
13
|
+
locale: string;
|
|
14
|
+
/** IANA zone. Required, always. */
|
|
15
|
+
zone: TimeZone;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FormatDateTimeOptions extends FormatContext {
|
|
19
|
+
/** Sets both date and time style; `dateStyle`/`timeStyle` override it. */
|
|
20
|
+
style?: DateTimeStyle;
|
|
21
|
+
dateStyle?: DateTimeStyle;
|
|
22
|
+
timeStyle?: DateTimeStyle;
|
|
23
|
+
hour12?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `14 Mar 2026, 09:00` in `en-GB` / `Europe/Berlin`. */
|
|
27
|
+
export function formatDateTime(at: Instant, options: FormatDateTimeOptions): string {
|
|
28
|
+
const style = options.style ?? 'medium';
|
|
29
|
+
return formatterFor(options.locale, {
|
|
30
|
+
timeZone: assertTimeZone(options.zone),
|
|
31
|
+
dateStyle: options.dateStyle ?? style,
|
|
32
|
+
timeStyle: options.timeStyle ?? (style === 'full' || style === 'long' ? 'medium' : style),
|
|
33
|
+
...(options.hour12 === undefined ? {} : { hour12: options.hour12 }),
|
|
34
|
+
}).format(at);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatDate(
|
|
38
|
+
at: Instant,
|
|
39
|
+
options: FormatContext & { style?: DateTimeStyle },
|
|
40
|
+
): string {
|
|
41
|
+
return formatterFor(options.locale, {
|
|
42
|
+
timeZone: assertTimeZone(options.zone),
|
|
43
|
+
dateStyle: options.style ?? 'medium',
|
|
44
|
+
}).format(at);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatTime(
|
|
48
|
+
at: Instant,
|
|
49
|
+
options: FormatContext & { style?: DateTimeStyle; hour12?: boolean },
|
|
50
|
+
): string {
|
|
51
|
+
return formatterFor(options.locale, {
|
|
52
|
+
timeZone: assertTimeZone(options.zone),
|
|
53
|
+
timeStyle: options.style ?? 'short',
|
|
54
|
+
...(options.hour12 === undefined ? {} : { hour12: options.hour12 }),
|
|
55
|
+
}).format(at);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `14 Mar 2026, 09:00 (GMT+1)` — the offset made visible.
|
|
60
|
+
* Built with `timeZoneName: 'shortOffset'` + `formatToParts` so the offset is appended
|
|
61
|
+
* in a fixed position instead of wherever the locale pattern happens to put it.
|
|
62
|
+
*/
|
|
63
|
+
export function formatWithOffset(at: Instant, options: FormatDateTimeOptions): string {
|
|
64
|
+
const style = options.style ?? 'medium';
|
|
65
|
+
// `timeZoneName` is a component option, and Intl forbids mixing those with dateStyle /
|
|
66
|
+
// timeStyle — so the components are spelled out here instead.
|
|
67
|
+
const parts = formatterFor(options.locale, {
|
|
68
|
+
timeZone: assertTimeZone(options.zone),
|
|
69
|
+
year: 'numeric',
|
|
70
|
+
month: style === 'short' ? 'numeric' : style === 'medium' ? 'short' : 'long',
|
|
71
|
+
day: 'numeric',
|
|
72
|
+
hour: '2-digit',
|
|
73
|
+
minute: '2-digit',
|
|
74
|
+
...(options.hour12 === undefined ? { hourCycle: 'h23' as const } : { hour12: options.hour12 }),
|
|
75
|
+
timeZoneName: 'shortOffset',
|
|
76
|
+
}).formatToParts(at);
|
|
77
|
+
|
|
78
|
+
const offset = parts.find((part) => part.type === 'timeZoneName')?.value ?? '';
|
|
79
|
+
const text = parts
|
|
80
|
+
.filter((part) => part.type !== 'timeZoneName')
|
|
81
|
+
.map((part) => part.value)
|
|
82
|
+
.join('')
|
|
83
|
+
.replace(/[\s,]+$/u, '')
|
|
84
|
+
.trim();
|
|
85
|
+
return offset === '' ? text : `${text} (${offset})`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** ISO-8601 date parts in a zone, for `<input type="date">` and CSV columns. */
|
|
89
|
+
export function formatIsoDate(at: Instant, zone: TimeZone): string {
|
|
90
|
+
const parts = formatterFor('en-CA', {
|
|
91
|
+
timeZone: assertTimeZone(zone),
|
|
92
|
+
year: 'numeric',
|
|
93
|
+
month: '2-digit',
|
|
94
|
+
day: '2-digit',
|
|
95
|
+
}).format(at);
|
|
96
|
+
return parts.replace(/\//g, '-');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface FormatRelativeOptions extends Omit<FormatContext, 'zone'> {
|
|
100
|
+
/** The reference point. Pass `now(clock)` — never let this default to a live clock. */
|
|
101
|
+
now: Instant;
|
|
102
|
+
numeric?: 'always' | 'auto';
|
|
103
|
+
style?: 'long' | 'short' | 'narrow';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const RELATIVE_UNITS: readonly [Intl.RelativeTimeFormatUnit, number][] = [
|
|
107
|
+
['year', 31_536_000_000],
|
|
108
|
+
['month', 2_592_000_000],
|
|
109
|
+
['week', 604_800_000],
|
|
110
|
+
['day', 86_400_000],
|
|
111
|
+
['hour', 3_600_000],
|
|
112
|
+
['minute', 60_000],
|
|
113
|
+
['second', 1000],
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
/** `in 3 days` / `2 hours ago`, picking the largest unit that fits. */
|
|
117
|
+
export function formatRelative(at: Instant, options: FormatRelativeOptions): string {
|
|
118
|
+
const delta = differenceMs(options.now, at);
|
|
119
|
+
const formatter = new Intl.RelativeTimeFormat(options.locale, {
|
|
120
|
+
numeric: options.numeric ?? 'auto',
|
|
121
|
+
style: options.style ?? 'long',
|
|
122
|
+
});
|
|
123
|
+
const magnitude = Math.abs(delta);
|
|
124
|
+
for (const [unit, ms] of RELATIVE_UNITS) {
|
|
125
|
+
if (magnitude >= ms) {
|
|
126
|
+
return formatter.format(Math.trunc(delta / ms), unit);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return formatter.format(0, 'second');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** `14–16 Mar 2026` — one call, so the locale decides how to collapse the range. */
|
|
133
|
+
export function formatRange(from: Instant, to: Instant, options: FormatDateTimeOptions): string {
|
|
134
|
+
const style = options.style ?? 'medium';
|
|
135
|
+
const formatter = formatterFor(options.locale, {
|
|
136
|
+
timeZone: assertTimeZone(options.zone),
|
|
137
|
+
dateStyle: options.dateStyle ?? style,
|
|
138
|
+
...(options.timeStyle === undefined ? {} : { timeStyle: options.timeStyle }),
|
|
139
|
+
}) as Intl.DateTimeFormat & {
|
|
140
|
+
formatRange?: (start: Date, end: Date) => string;
|
|
141
|
+
};
|
|
142
|
+
// `formatRange` is ES2021; fall back to two formatted endpoints on older engines.
|
|
143
|
+
if (typeof formatter.formatRange === 'function') return formatter.formatRange(from, to);
|
|
144
|
+
return `${formatter.format(from)} – ${formatter.format(to)}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const ORDINAL_SUFFIX: Record<Intl.LDMLPluralRule, string> = {
|
|
148
|
+
one: 'st',
|
|
149
|
+
two: 'nd',
|
|
150
|
+
few: 'rd',
|
|
151
|
+
other: 'th',
|
|
152
|
+
zero: 'th',
|
|
153
|
+
many: 'th',
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `Intl` renders `November 5, 2011`, never `5th of November`. When a design asks for the
|
|
158
|
+
* ordinal, build it from `Intl.PluralRules` with `type: 'ordinal'` — English-only by
|
|
159
|
+
* nature, which is why it is a helper and not the default date format.
|
|
160
|
+
*/
|
|
161
|
+
export function ordinal(value: number, locale = 'en'): string {
|
|
162
|
+
const category = new Intl.PluralRules(locale, { type: 'ordinal' }).select(value);
|
|
163
|
+
return `${value}${ORDINAL_SUFFIX[category]}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const cache = new Map<string, Intl.DateTimeFormat>();
|
|
167
|
+
|
|
168
|
+
function formatterFor(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
|
169
|
+
const key = `${locale}|${JSON.stringify(options)}`;
|
|
170
|
+
const cached = cache.get(key);
|
|
171
|
+
if (cached !== undefined) return cached;
|
|
172
|
+
const formatter = new Intl.DateTimeFormat(locale, options);
|
|
173
|
+
cache.set(key, formatter);
|
|
174
|
+
return formatter;
|
|
175
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Public surface of @ultimat3/time. Explicit exports only. */
|
|
2
|
+
export { addBusinessDays, type BusinessCalendar, businessDaysBetween, type IsoWeekday, isBusinessDay, isHoliday, isWeekend, nextBusinessDay, WEEKEND_FRI_SAT, WEEKEND_SAT_SUN, WEEKEND_SUN_ONLY, } from './business';
|
|
3
|
+
export { attachTimeZone, configureTime, currentTimeZone, resolveTimeZone, TIMEZONE_HEADER, type TimeConfig, type TimeZoneResolution, type TimeZoneSourceName, type TimeZoneSources, timeConfig, timeZoneOf, } from './context';
|
|
4
|
+
export { type CronExpression, type CronPhrases, DEFAULT_CRON_PHRASES, describeCron, firedSince, isValidCron, matchesCron, nextCronOccurrence, nextCronOccurrenceMs, nextCronOccurrences, parseCron, } from './cron';
|
|
5
|
+
export { DAY, type FormatDurationOptions, formatDuration, formatDurationIso, HOUR, MINUTE, MS, parseDuration, SECOND, toMs, toSeconds, WEEK, } from './duration';
|
|
6
|
+
export { cronInvalid, dstAmbiguous, dstNonexistent, durationInvalid, instantInvalid, TIME_ERROR_CODES, TimeError, type TimeErrorCode, timezoneInvalid, } from './errors';
|
|
7
|
+
export { type DateTimeStyle, type FormatContext, type FormatDateTimeOptions, type FormatRelativeOptions, formatDate, formatDateTime, formatIsoDate, formatRange, formatRelative, formatTime, formatWithOffset, ordinal, } from './format';
|
|
8
|
+
export { addMs, compareInstants, differenceMs, EPOCH, fromEpochMs, fromEpochSeconds, fromIso, type Instant, instant, isAfter, isBefore, isInstant, now, subtractMs, toEpochMs, toIso, toIsoDateUtc, } from './instant';
|
|
9
|
+
export { type LocalSlot, nextLocalSlot, nextLocalSlots, nextWeeklySlot, type WeeklySlot, } from './schedule';
|
|
10
|
+
export { addDaysInZone, endOfDay, type FromZonedOptions, type FromZonedResult, fromZoned, fromZonedDetailed, type GapPolicy, isoDateInZone, isSameLocalDay, type OverlapPolicy, startOfDay, toZoned, type WallClock, type ZonedDateTime, type ZonedResolution, } from './zoned';
|
|
11
|
+
export { assertTimeZone, isValidTimeZone, observesDst, offsetAt, offsetLabel, type TimeZone, UTC, type ZoneParts, zoneAbbrev, zonePartsAt, } from './zones';
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,+DAA+D;AAE/D,OAAO,EACL,eAAe,EACf,KAAK,gBAAgB,EACrB,mBAAmB,EACnB,KAAK,UAAU,EACf,aAAa,EACb,SAAS,EACT,SAAS,EACT,eAAe,EACf,eAAe,EACf,eAAe,EACf,gBAAgB,GACjB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,cAAc,EACd,aAAa,EACb,eAAe,EACf,eAAe,EACf,eAAe,EACf,KAAK,UAAU,EACf,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,UAAU,EACV,UAAU,GACX,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,oBAAoB,EACpB,YAAY,EACZ,UAAU,EACV,WAAW,EACX,WAAW,EACX,kBAAkB,EAClB,oBAAoB,EACpB,mBAAmB,EACnB,SAAS,GACV,MAAM,QAAQ,CAAC;AAChB,OAAO,EACL,GAAG,EACH,KAAK,qBAAqB,EAC1B,cAAc,EACd,iBAAiB,EACjB,IAAI,EACJ,MAAM,EACN,EAAE,EACF,aAAa,EACb,MAAM,EACN,IAAI,EACJ,SAAS,EACT,IAAI,GACL,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,WAAW,EACX,YAAY,EACZ,cAAc,EACd,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,SAAS,EACT,KAAK,aAAa,EAClB,eAAe,GAChB,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,EAC1B,UAAU,EACV,cAAc,EACd,aAAa,EACb,WAAW,EACX,cAAc,EACd,UAAU,EACV,gBAAgB,EAChB,OAAO,GACR,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,KAAK,EACL,eAAe,EACf,YAAY,EACZ,KAAK,EACL,WAAW,EACX,gBAAgB,EAChB,OAAO,EACP,KAAK,OAAO,EACZ,OAAO,EACP,OAAO,EACP,QAAQ,EACR,SAAS,EACT,GAAG,EACH,UAAU,EACV,SAAS,EACT,KAAK,EACL,YAAY,GACb,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,KAAK,SAAS,EACd,aAAa,EACb,cAAc,EACd,cAAc,EACd,KAAK,UAAU,GAChB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,aAAa,EACb,QAAQ,EACR,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,SAAS,EACT,iBAAiB,EACjB,KAAK,SAAS,EACd,aAAa,EACb,cAAc,EACd,KAAK,aAAa,EAClB,UAAU,EACV,OAAO,EACP,KAAK,SAAS,EACd,KAAK,aAAa,EAClB,KAAK,eAAe,GACrB,MAAM,SAAS,CAAC;AACjB,OAAO,EACL,cAAc,EACd,eAAe,EACf,WAAW,EACX,QAAQ,EACR,WAAW,EACX,KAAK,QAAQ,EACb,GAAG,EACH,KAAK,SAAS,EACd,UAAU,EACV,WAAW,GACZ,MAAM,SAAS,CAAC"}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Public surface of @ultimat3/time. Explicit exports only. */
|
|
2
|
+
export { addBusinessDays, businessDaysBetween, isBusinessDay, isHoliday, isWeekend, nextBusinessDay, WEEKEND_FRI_SAT, WEEKEND_SAT_SUN, WEEKEND_SUN_ONLY, } from './business';
|
|
3
|
+
export { attachTimeZone, configureTime, currentTimeZone, resolveTimeZone, TIMEZONE_HEADER, timeConfig, timeZoneOf, } from './context';
|
|
4
|
+
export { DEFAULT_CRON_PHRASES, describeCron, firedSince, isValidCron, matchesCron, nextCronOccurrence, nextCronOccurrenceMs, nextCronOccurrences, parseCron, } from './cron';
|
|
5
|
+
export { DAY, formatDuration, formatDurationIso, HOUR, MINUTE, MS, parseDuration, SECOND, toMs, toSeconds, WEEK, } from './duration';
|
|
6
|
+
export { cronInvalid, dstAmbiguous, dstNonexistent, durationInvalid, instantInvalid, TIME_ERROR_CODES, TimeError, timezoneInvalid, } from './errors';
|
|
7
|
+
export { formatDate, formatDateTime, formatIsoDate, formatRange, formatRelative, formatTime, formatWithOffset, ordinal, } from './format';
|
|
8
|
+
export { addMs, compareInstants, differenceMs, EPOCH, fromEpochMs, fromEpochSeconds, fromIso, instant, isAfter, isBefore, isInstant, now, subtractMs, toEpochMs, toIso, toIsoDateUtc, } from './instant';
|
|
9
|
+
export { nextLocalSlot, nextLocalSlots, nextWeeklySlot, } from './schedule';
|
|
10
|
+
export { addDaysInZone, endOfDay, fromZoned, fromZonedDetailed, isoDateInZone, isSameLocalDay, startOfDay, toZoned, } from './zoned';
|
|
11
|
+
export { assertTimeZone, isValidTimeZone, observesDst, offsetAt, offsetLabel, UTC, zoneAbbrev, zonePartsAt, } from './zones';
|
|
12
|
+
//# sourceMappingURL=index.js.map
|
package/src/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,+DAA+D;AAE/D,OAAO,EACL,eAAe,EAEf,mBAAmB,EAEnB,aAAa,EACb,SAAS,EACT,SAAS,EACT,eAAe,EACf,eAAe,EACf,eAAe,EACf,gBAAgB,GACjB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,cAAc,EACd,aAAa,EACb,eAAe,EACf,eAAe,EACf,eAAe,EAKf,UAAU,EACV,UAAU,GACX,MAAM,WAAW,CAAC;AACnB,OAAO,EAGL,oBAAoB,EACpB,YAAY,EACZ,UAAU,EACV,WAAW,EACX,WAAW,EACX,kBAAkB,EAClB,oBAAoB,EACpB,mBAAmB,EACnB,SAAS,GACV,MAAM,QAAQ,CAAC;AAChB,OAAO,EACL,GAAG,EAEH,cAAc,EACd,iBAAiB,EACjB,IAAI,EACJ,MAAM,EACN,EAAE,EACF,aAAa,EACb,MAAM,EACN,IAAI,EACJ,SAAS,EACT,IAAI,GACL,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,WAAW,EACX,YAAY,EACZ,cAAc,EACd,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,SAAS,EAET,eAAe,GAChB,MAAM,UAAU,CAAC;AAClB,OAAO,EAKL,UAAU,EACV,cAAc,EACd,aAAa,EACb,WAAW,EACX,cAAc,EACd,UAAU,EACV,gBAAgB,EAChB,OAAO,GACR,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,KAAK,EACL,eAAe,EACf,YAAY,EACZ,KAAK,EACL,WAAW,EACX,gBAAgB,EAChB,OAAO,EAEP,OAAO,EACP,OAAO,EACP,QAAQ,EACR,SAAS,EACT,GAAG,EACH,UAAU,EACV,SAAS,EACT,KAAK,EACL,YAAY,GACb,MAAM,WAAW,CAAC;AACnB,OAAO,EAEL,aAAa,EACb,cAAc,EACd,cAAc,GAEf,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,aAAa,EACb,QAAQ,EAGR,SAAS,EACT,iBAAiB,EAEjB,aAAa,EACb,cAAc,EAEd,UAAU,EACV,OAAO,GAIR,MAAM,SAAS,CAAC;AACjB,OAAO,EACL,cAAc,EACd,eAAe,EACf,WAAW,EACX,QAAQ,EACR,WAAW,EAEX,GAAG,EAEH,UAAU,EACV,WAAW,GACZ,MAAM,SAAS,CAAC"}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/** Public surface of @ultimat3/time. Explicit exports only. */
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
addBusinessDays,
|
|
5
|
+
type BusinessCalendar,
|
|
6
|
+
businessDaysBetween,
|
|
7
|
+
type IsoWeekday,
|
|
8
|
+
isBusinessDay,
|
|
9
|
+
isHoliday,
|
|
10
|
+
isWeekend,
|
|
11
|
+
nextBusinessDay,
|
|
12
|
+
WEEKEND_FRI_SAT,
|
|
13
|
+
WEEKEND_SAT_SUN,
|
|
14
|
+
WEEKEND_SUN_ONLY,
|
|
15
|
+
} from './business';
|
|
16
|
+
export {
|
|
17
|
+
attachTimeZone,
|
|
18
|
+
configureTime,
|
|
19
|
+
currentTimeZone,
|
|
20
|
+
resolveTimeZone,
|
|
21
|
+
TIMEZONE_HEADER,
|
|
22
|
+
type TimeConfig,
|
|
23
|
+
type TimeZoneResolution,
|
|
24
|
+
type TimeZoneSourceName,
|
|
25
|
+
type TimeZoneSources,
|
|
26
|
+
timeConfig,
|
|
27
|
+
timeZoneOf,
|
|
28
|
+
} from './context';
|
|
29
|
+
export {
|
|
30
|
+
type CronExpression,
|
|
31
|
+
type CronPhrases,
|
|
32
|
+
describeCron,
|
|
33
|
+
firedSince,
|
|
34
|
+
isValidCron,
|
|
35
|
+
matchesCron,
|
|
36
|
+
nextCronOccurrence,
|
|
37
|
+
nextCronOccurrenceMs,
|
|
38
|
+
nextCronOccurrences,
|
|
39
|
+
parseCron,
|
|
40
|
+
} from './cron';
|
|
41
|
+
export {
|
|
42
|
+
DAY,
|
|
43
|
+
type FormatDurationOptions,
|
|
44
|
+
formatDuration,
|
|
45
|
+
formatDurationIso,
|
|
46
|
+
HOUR,
|
|
47
|
+
MINUTE,
|
|
48
|
+
MS,
|
|
49
|
+
parseDuration,
|
|
50
|
+
SECOND,
|
|
51
|
+
toMs,
|
|
52
|
+
toSeconds,
|
|
53
|
+
WEEK,
|
|
54
|
+
} from './duration';
|
|
55
|
+
export {
|
|
56
|
+
cronInvalid,
|
|
57
|
+
dstAmbiguous,
|
|
58
|
+
dstNonexistent,
|
|
59
|
+
durationInvalid,
|
|
60
|
+
instantInvalid,
|
|
61
|
+
localeInvalid,
|
|
62
|
+
TIME_ERROR_CODES,
|
|
63
|
+
TIME_ERROR_TITLES,
|
|
64
|
+
TimeError,
|
|
65
|
+
type TimeErrorCode,
|
|
66
|
+
timezoneInvalid,
|
|
67
|
+
} from './errors';
|
|
68
|
+
export {
|
|
69
|
+
type DateTimeStyle,
|
|
70
|
+
type FormatContext,
|
|
71
|
+
type FormatDateTimeOptions,
|
|
72
|
+
type FormatRelativeOptions,
|
|
73
|
+
formatDate,
|
|
74
|
+
formatDateTime,
|
|
75
|
+
formatIsoDate,
|
|
76
|
+
formatRange,
|
|
77
|
+
formatRelative,
|
|
78
|
+
formatTime,
|
|
79
|
+
formatWithOffset,
|
|
80
|
+
ordinal,
|
|
81
|
+
} from './format';
|
|
82
|
+
export {
|
|
83
|
+
addMs,
|
|
84
|
+
compareInstants,
|
|
85
|
+
differenceMs,
|
|
86
|
+
EPOCH,
|
|
87
|
+
fromEpochMs,
|
|
88
|
+
fromEpochSeconds,
|
|
89
|
+
fromIso,
|
|
90
|
+
type Instant,
|
|
91
|
+
instant,
|
|
92
|
+
isAfter,
|
|
93
|
+
isBefore,
|
|
94
|
+
isInstant,
|
|
95
|
+
now,
|
|
96
|
+
subtractMs,
|
|
97
|
+
toEpochMs,
|
|
98
|
+
toIso,
|
|
99
|
+
toIsoDateUtc,
|
|
100
|
+
} from './instant';
|
|
101
|
+
export {
|
|
102
|
+
type LocalSlot,
|
|
103
|
+
nextLocalSlot,
|
|
104
|
+
nextLocalSlots,
|
|
105
|
+
nextWeeklySlot,
|
|
106
|
+
type WeeklySlot,
|
|
107
|
+
} from './schedule';
|
|
108
|
+
export {
|
|
109
|
+
addDaysInZone,
|
|
110
|
+
daysBetween,
|
|
111
|
+
endOfDay,
|
|
112
|
+
type FromZonedOptions,
|
|
113
|
+
type FromZonedResult,
|
|
114
|
+
fromZoned,
|
|
115
|
+
fromZonedDetailed,
|
|
116
|
+
type GapPolicy,
|
|
117
|
+
isoDateInZone,
|
|
118
|
+
isSameLocalDay,
|
|
119
|
+
type OverlapPolicy,
|
|
120
|
+
startOfDay,
|
|
121
|
+
toZoned,
|
|
122
|
+
type WallClock,
|
|
123
|
+
type ZonedDateTime,
|
|
124
|
+
type ZonedResolution,
|
|
125
|
+
} from './zoned';
|
|
126
|
+
export {
|
|
127
|
+
assertTimeZone,
|
|
128
|
+
isValidTimeZone,
|
|
129
|
+
observesDst,
|
|
130
|
+
offsetAt,
|
|
131
|
+
offsetLabel,
|
|
132
|
+
type TimeZone,
|
|
133
|
+
UTC,
|
|
134
|
+
type ZoneParts,
|
|
135
|
+
zoneAbbrev,
|
|
136
|
+
zonePartsAt,
|
|
137
|
+
} from './zones';
|
package/src/instant.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An `Instant` is a point on the UTC timeline. **All storage is UTC** — columns are
|
|
3
|
+
* `timestamptz`, wire format is ISO-8601 with `Z`, and no zone is attached to a stored
|
|
4
|
+
* value. Zones exist only at the edge, in `format.ts` and `zoned.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import { type Clock } from '@ultimat3/core';
|
|
7
|
+
declare const instantBrand: unique symbol;
|
|
8
|
+
/** A `Date` that has been proven valid and is documented as UTC. */
|
|
9
|
+
export type Instant = Date & {
|
|
10
|
+
readonly [instantBrand]: 'utc';
|
|
11
|
+
};
|
|
12
|
+
/** Wrap a `Date` from an untrusted source (a DB driver, a parsed payload). */
|
|
13
|
+
export declare function instant(value: Date): Instant;
|
|
14
|
+
/** ISO-8601 in, `Instant` out. An offset or `Z` is required — a bare local string is a bug. */
|
|
15
|
+
export declare function fromIso(iso: string): Instant;
|
|
16
|
+
/** The only serialization: `2026-03-14T09:00:00.000Z`. */
|
|
17
|
+
export declare function toIso(at: Instant): string;
|
|
18
|
+
/** Date-only ISO form in UTC. Use `formatIsoDate` when you need it in a zone. */
|
|
19
|
+
export declare function toIsoDateUtc(at: Instant): string;
|
|
20
|
+
export declare function fromEpochMs(ms: number): Instant;
|
|
21
|
+
export declare function toEpochMs(at: Instant): number;
|
|
22
|
+
export declare function fromEpochSeconds(seconds: number): Instant;
|
|
23
|
+
/**
|
|
24
|
+
* The clock is always injected. Tests freeze time by passing a fake clock; nothing in
|
|
25
|
+
* the framework calls `Date.now()` directly.
|
|
26
|
+
*/
|
|
27
|
+
export declare function now(clock?: Clock): Instant;
|
|
28
|
+
export declare function addMs(at: Instant, ms: number): Instant;
|
|
29
|
+
export declare function subtractMs(at: Instant, ms: number): Instant;
|
|
30
|
+
/** Signed milliseconds from `from` to `to`. */
|
|
31
|
+
export declare function differenceMs(from: Instant, to: Instant): number;
|
|
32
|
+
export declare function isBefore(left: Instant, right: Instant): boolean;
|
|
33
|
+
export declare function isAfter(left: Instant, right: Instant): boolean;
|
|
34
|
+
export declare function compareInstants(left: Instant, right: Instant): -1 | 0 | 1;
|
|
35
|
+
export declare function isInstant(value: unknown): value is Instant;
|
|
36
|
+
export declare const EPOCH: Instant;
|
|
37
|
+
export {};
|
|
38
|
+
//# sourceMappingURL=instant.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"instant.d.ts","sourceRoot":"","sources":["instant.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,KAAK,KAAK,EAAe,MAAM,gBAAgB,CAAC;AAGzD,OAAO,CAAC,MAAM,YAAY,EAAE,OAAO,MAAM,CAAC;AAE1C,oEAAoE;AACpE,MAAM,MAAM,OAAO,GAAG,IAAI,GAAG;IAAE,QAAQ,CAAC,CAAC,YAAY,CAAC,EAAE,KAAK,CAAA;CAAE,CAAC;AAEhE,8EAA8E;AAC9E,wBAAgB,OAAO,CAAC,KAAK,EAAE,IAAI,GAAG,OAAO,CAG5C;AAED,+FAA+F;AAC/F,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAI5C;AAED,0DAA0D;AAC1D,wBAAgB,KAAK,CAAC,EAAE,EAAE,OAAO,GAAG,MAAM,CAEzC;AAED,iFAAiF;AACjF,wBAAgB,YAAY,CAAC,EAAE,EAAE,OAAO,GAAG,MAAM,CAEhD;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAG/C;AAED,wBAAgB,SAAS,CAAC,EAAE,EAAE,OAAO,GAAG,MAAM,CAE7C;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED;;;GAGG;AACH,wBAAgB,GAAG,CAAC,KAAK,GAAE,KAAmB,GAAG,OAAO,CAEvD;AAED,wBAAgB,KAAK,CAAC,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED,wBAAgB,UAAU,CAAC,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAE3D;AAED,+CAA+C;AAC/C,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,GAAG,MAAM,CAE/D;AAED,wBAAgB,QAAQ,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAE/D;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAE9D;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAGzE;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,OAAO,CAE1D;AAED,eAAO,MAAM,KAAK,EAAE,OAAgC,CAAC"}
|
package/src/instant.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An `Instant` is a point on the UTC timeline. **All storage is UTC** — columns are
|
|
3
|
+
* `timestamptz`, wire format is ISO-8601 with `Z`, and no zone is attached to a stored
|
|
4
|
+
* value. Zones exist only at the edge, in `format.ts` and `zoned.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import { systemClock } from '@ultimat3/core';
|
|
7
|
+
import { instantInvalid } from './errors';
|
|
8
|
+
/** Wrap a `Date` from an untrusted source (a DB driver, a parsed payload). */
|
|
9
|
+
export function instant(value) {
|
|
10
|
+
if (Number.isNaN(value.getTime()))
|
|
11
|
+
throw instantInvalid(String(value));
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
/** ISO-8601 in, `Instant` out. An offset or `Z` is required — a bare local string is a bug. */
|
|
15
|
+
export function fromIso(iso) {
|
|
16
|
+
const parsed = new Date(iso);
|
|
17
|
+
if (Number.isNaN(parsed.getTime()))
|
|
18
|
+
throw instantInvalid(iso);
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
/** The only serialization: `2026-03-14T09:00:00.000Z`. */
|
|
22
|
+
export function toIso(at) {
|
|
23
|
+
return at.toISOString();
|
|
24
|
+
}
|
|
25
|
+
/** Date-only ISO form in UTC. Use `formatIsoDate` when you need it in a zone. */
|
|
26
|
+
export function toIsoDateUtc(at) {
|
|
27
|
+
return at.toISOString().slice(0, 10);
|
|
28
|
+
}
|
|
29
|
+
export function fromEpochMs(ms) {
|
|
30
|
+
if (!Number.isFinite(ms))
|
|
31
|
+
throw instantInvalid(String(ms));
|
|
32
|
+
return new Date(ms);
|
|
33
|
+
}
|
|
34
|
+
export function toEpochMs(at) {
|
|
35
|
+
return at.getTime();
|
|
36
|
+
}
|
|
37
|
+
export function fromEpochSeconds(seconds) {
|
|
38
|
+
return fromEpochMs(seconds * 1000);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The clock is always injected. Tests freeze time by passing a fake clock; nothing in
|
|
42
|
+
* the framework calls `Date.now()` directly.
|
|
43
|
+
*/
|
|
44
|
+
export function now(clock = systemClock) {
|
|
45
|
+
return fromEpochMs(epochMsOf(clock.now()));
|
|
46
|
+
}
|
|
47
|
+
export function addMs(at, ms) {
|
|
48
|
+
return fromEpochMs(at.getTime() + ms);
|
|
49
|
+
}
|
|
50
|
+
export function subtractMs(at, ms) {
|
|
51
|
+
return fromEpochMs(at.getTime() - ms);
|
|
52
|
+
}
|
|
53
|
+
/** Signed milliseconds from `from` to `to`. */
|
|
54
|
+
export function differenceMs(from, to) {
|
|
55
|
+
return to.getTime() - from.getTime();
|
|
56
|
+
}
|
|
57
|
+
export function isBefore(left, right) {
|
|
58
|
+
return left.getTime() < right.getTime();
|
|
59
|
+
}
|
|
60
|
+
export function isAfter(left, right) {
|
|
61
|
+
return left.getTime() > right.getTime();
|
|
62
|
+
}
|
|
63
|
+
export function compareInstants(left, right) {
|
|
64
|
+
if (left.getTime() < right.getTime())
|
|
65
|
+
return -1;
|
|
66
|
+
return left.getTime() > right.getTime() ? 1 : 0;
|
|
67
|
+
}
|
|
68
|
+
export function isInstant(value) {
|
|
69
|
+
return value instanceof Date && !Number.isNaN(value.getTime());
|
|
70
|
+
}
|
|
71
|
+
export const EPOCH = new Date(0);
|
|
72
|
+
/** Tolerates a `Clock` whose `now()` returns either a `Date` or epoch milliseconds. */
|
|
73
|
+
function epochMsOf(value) {
|
|
74
|
+
return typeof value === 'number' ? value : value.getTime();
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=instant.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"instant.js","sourceRoot":"","sources":["instant.ts"],"names":[],"mappings":"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"}
|
package/src/instant.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An `Instant` is a point on the UTC timeline. **All storage is UTC** — columns are
|
|
3
|
+
* `timestamptz`, wire format is ISO-8601 with `Z`, and no zone is attached to a stored
|
|
4
|
+
* value. Zones exist only at the edge, in `format.ts` and `zoned.ts`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { type Clock, systemClock } from '@ultimat3/core';
|
|
8
|
+
import { instantInvalid } from './errors';
|
|
9
|
+
|
|
10
|
+
declare const instantBrand: unique symbol;
|
|
11
|
+
|
|
12
|
+
/** A `Date` that has been proven valid and is documented as UTC. */
|
|
13
|
+
export type Instant = Date & { readonly [instantBrand]: 'utc' };
|
|
14
|
+
|
|
15
|
+
/** Wrap a `Date` from an untrusted source (a DB driver, a parsed payload). */
|
|
16
|
+
export function instant(value: Date): Instant {
|
|
17
|
+
if (Number.isNaN(value.getTime())) throw instantInvalid(String(value));
|
|
18
|
+
return value as Instant;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** ISO-8601 in, `Instant` out. An offset or `Z` is required — a bare local string is a bug. */
|
|
22
|
+
export function fromIso(iso: string): Instant {
|
|
23
|
+
const parsed = new Date(iso);
|
|
24
|
+
if (Number.isNaN(parsed.getTime())) throw instantInvalid(iso);
|
|
25
|
+
return parsed as Instant;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The only serialization: `2026-03-14T09:00:00.000Z`. */
|
|
29
|
+
export function toIso(at: Instant): string {
|
|
30
|
+
return at.toISOString();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Date-only ISO form in UTC. Use `formatIsoDate` when you need it in a zone. */
|
|
34
|
+
export function toIsoDateUtc(at: Instant): string {
|
|
35
|
+
return at.toISOString().slice(0, 10);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function fromEpochMs(ms: number): Instant {
|
|
39
|
+
if (!Number.isFinite(ms)) throw instantInvalid(String(ms));
|
|
40
|
+
return new Date(ms) as Instant;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function toEpochMs(at: Instant): number {
|
|
44
|
+
return at.getTime();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function fromEpochSeconds(seconds: number): Instant {
|
|
48
|
+
return fromEpochMs(seconds * 1000);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The clock is always injected. Tests freeze time by passing a fake clock; nothing in
|
|
53
|
+
* the framework calls `Date.now()` directly.
|
|
54
|
+
*/
|
|
55
|
+
export function now(clock: Clock = systemClock): Instant {
|
|
56
|
+
return fromEpochMs(epochMsOf(clock.now()));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function addMs(at: Instant, ms: number): Instant {
|
|
60
|
+
return fromEpochMs(at.getTime() + ms);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function subtractMs(at: Instant, ms: number): Instant {
|
|
64
|
+
return fromEpochMs(at.getTime() - ms);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Signed milliseconds from `from` to `to`. */
|
|
68
|
+
export function differenceMs(from: Instant, to: Instant): number {
|
|
69
|
+
return to.getTime() - from.getTime();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isBefore(left: Instant, right: Instant): boolean {
|
|
73
|
+
return left.getTime() < right.getTime();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isAfter(left: Instant, right: Instant): boolean {
|
|
77
|
+
return left.getTime() > right.getTime();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function compareInstants(left: Instant, right: Instant): -1 | 0 | 1 {
|
|
81
|
+
if (left.getTime() < right.getTime()) return -1;
|
|
82
|
+
return left.getTime() > right.getTime() ? 1 : 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isInstant(value: unknown): value is Instant {
|
|
86
|
+
return value instanceof Date && !Number.isNaN(value.getTime());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const EPOCH: Instant = new Date(0) as Instant;
|
|
90
|
+
|
|
91
|
+
/** Tolerates a `Clock` whose `now()` returns either a `Date` or epoch milliseconds. */
|
|
92
|
+
function epochMsOf(value: Date | number): number {
|
|
93
|
+
return typeof value === 'number' ? value : value.getTime();
|
|
94
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
import type { Instant } from './instant';
|
|
6
|
+
import { type TimeZone } from './zones';
|
|
7
|
+
export interface LocalSlot {
|
|
8
|
+
zone: TimeZone;
|
|
9
|
+
/** 0–23, wall clock in `zone`. */
|
|
10
|
+
hour: number;
|
|
11
|
+
/** 0–59. */
|
|
12
|
+
minute?: number;
|
|
13
|
+
second?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function nextLocalSlot(slot: LocalSlot, after: Instant): Instant;
|
|
16
|
+
/** The next `count` daily slots — a preview for the schedule screen. */
|
|
17
|
+
export declare function nextLocalSlots(slot: LocalSlot, after: Instant, count: number): Instant[];
|
|
18
|
+
export interface WeeklySlot extends LocalSlot {
|
|
19
|
+
/** ISO weekday: 1 = Monday … 7 = Sunday. */
|
|
20
|
+
weekday: number;
|
|
21
|
+
}
|
|
22
|
+
/** Next `weekday` at the local time, strictly after `after`. */
|
|
23
|
+
export declare function nextWeeklySlot(slot: WeeklySlot, after: Instant): Instant;
|
|
24
|
+
//# sourceMappingURL=schedule.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schedule.d.ts","sourceRoot":"","sources":["schedule.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,SAAS,CAAC;AAExD,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,QAAQ,CAAC;IACf,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,YAAY;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAgBD,wBAAgB,aAAa,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CA6BtE;AAED,wEAAwE;AACxE,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,EAAE,CAQxF;AAED,MAAM,WAAW,UAAW,SAAQ,SAAS;IAC3C,4CAA4C;IAC5C,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,gEAAgE;AAChE,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAQxE"}
|
package/src/schedule.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
import { scheduleInvalid, timezoneInvalid } from './errors';
|
|
6
|
+
import { fromZoned, toZoned } from './zoned';
|
|
7
|
+
import { assertTimeZone } from './zones';
|
|
8
|
+
/**
|
|
9
|
+
* Next instant matching the local `HH:mm` in `zone`, strictly after `after`.
|
|
10
|
+
*
|
|
11
|
+
* Walks forward day by day on the *local* calendar rather than adding 86 400 000 ms, so
|
|
12
|
+
* a 23- or 25-hour day does not shift the slot. If the slot falls in a spring-forward
|
|
13
|
+
* gap, `{ gap: 'next' }` picks the first existing local time instead of skipping the day.
|
|
14
|
+
*/
|
|
15
|
+
/** Wall-clock fields are never wrapped or clamped — a shifted schedule beats no schedule. */
|
|
16
|
+
function assertWallField(field, value, max) {
|
|
17
|
+
if (!Number.isInteger(value) || value < 0 || value > max) {
|
|
18
|
+
throw scheduleInvalid(field, value, `an integer 0-${String(max)}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function nextLocalSlot(slot, after) {
|
|
22
|
+
assertTimeZone(slot.zone);
|
|
23
|
+
assertWallField('slot.hour', slot.hour, 23);
|
|
24
|
+
const minute = slot.minute ?? 0;
|
|
25
|
+
const second = slot.second ?? 0;
|
|
26
|
+
// Validated too: a minute of 90 would otherwise roll into the next hour and ship a
|
|
27
|
+
// schedule an hour off, which is exactly the class of bug this package exists to prevent.
|
|
28
|
+
assertWallField('slot.minute', minute, 59);
|
|
29
|
+
assertWallField('slot.second', second, 59);
|
|
30
|
+
const start = toZoned(after, slot.zone);
|
|
31
|
+
for (let dayOffset = 0; dayOffset < 4; dayOffset += 1) {
|
|
32
|
+
const candidate = fromZoned({
|
|
33
|
+
year: start.year,
|
|
34
|
+
month: start.month,
|
|
35
|
+
day: start.day + dayOffset,
|
|
36
|
+
hour: slot.hour,
|
|
37
|
+
minute,
|
|
38
|
+
second,
|
|
39
|
+
}, slot.zone, { gap: 'next', overlap: 'first' });
|
|
40
|
+
if (candidate.getTime() > after.getTime())
|
|
41
|
+
return candidate;
|
|
42
|
+
}
|
|
43
|
+
// Four local days always contain the slot; reaching here means the zone data is broken.
|
|
44
|
+
throw timezoneInvalid(slot.zone);
|
|
45
|
+
}
|
|
46
|
+
/** The next `count` daily slots — a preview for the schedule screen. */
|
|
47
|
+
export function nextLocalSlots(slot, after, count) {
|
|
48
|
+
const slots = [];
|
|
49
|
+
let cursor = after;
|
|
50
|
+
for (let index = 0; index < count; index += 1) {
|
|
51
|
+
cursor = nextLocalSlot(slot, cursor);
|
|
52
|
+
slots.push(cursor);
|
|
53
|
+
}
|
|
54
|
+
return slots;
|
|
55
|
+
}
|
|
56
|
+
/** Next `weekday` at the local time, strictly after `after`. */
|
|
57
|
+
export function nextWeeklySlot(slot, after) {
|
|
58
|
+
let cursor = after;
|
|
59
|
+
for (let index = 0; index < 8; index += 1) {
|
|
60
|
+
const candidate = nextLocalSlot(slot, cursor);
|
|
61
|
+
if (toZoned(candidate, slot.zone).weekday === slot.weekday)
|
|
62
|
+
return candidate;
|
|
63
|
+
cursor = candidate;
|
|
64
|
+
}
|
|
65
|
+
throw timezoneInvalid(slot.zone);
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=schedule.js.map
|