@classytic/primitives 0.8.0 → 0.9.1
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/CHANGELOG.md +41 -0
- package/dist/calendar.d.mts +46 -0
- package/dist/calendar.mjs +47 -0
- package/dist/{currency-B4rSdN7D.d.mts → currency-BCJnJBlb.d.mts} +10 -1
- package/dist/currency.d.mts +2 -2
- package/dist/currency.mjs +9 -1
- package/dist/money.d.mts +1 -1
- package/dist/timezone.d.mts +100 -0
- package/dist/timezone.mjs +238 -0
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,47 @@
|
|
|
3
3
|
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
4
4
|
adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## [0.9.1] - 2026-07-04
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`CURRENCY_PATTERN` exported from `/currency`** — the ISO 4217 regex
|
|
11
|
+
(`/^[A-Z]{3}$/`) behind `isCurrencyCode` / `toCurrencyCode`. Consumers
|
|
12
|
+
embed the SAME pattern in JSON-Schema-representable validators (zod
|
|
13
|
+
`.regex`, Mongoose `match`, OpenAPI `pattern`) instead of hand-rolling
|
|
14
|
+
copies — `.refine(isCurrencyCode)` validates at runtime but is dropped
|
|
15
|
+
by `z.toJSONSchema`, silently losing the constraint from generated API
|
|
16
|
+
docs.
|
|
17
|
+
|
|
18
|
+
## [0.9.0] - 2026-07-02
|
|
19
|
+
|
|
20
|
+
### Added — `/timezone`: IANA resolution + civil dates (the door for `/calendar`'s escape hatch)
|
|
21
|
+
|
|
22
|
+
`/calendar` owns wall-clock ARITHMETIC over a fixed `UtcOffsetMinutes` and
|
|
23
|
+
tells hosts to "resolve the offset for the instant and pass it in" — but the
|
|
24
|
+
stack had no shared resolver, so consumers hand-rolled `Intl.DateTimeFormat`
|
|
25
|
+
(three divergent copies found: catalog ×2, be-prod POS). `/timezone` is that
|
|
26
|
+
resolver — zero-dep (Intl IS the ICU tz database), Temporal-shaped names for
|
|
27
|
+
a future mechanical migration:
|
|
28
|
+
|
|
29
|
+
- **`zoneOffsetMinutes(instant, zone)`** — per-instant, DST-exact offset;
|
|
30
|
+
composes directly with `/calendar`: `startOfMonth(i, zoneOffsetMinutes(i, z))`.
|
|
31
|
+
- **`localTimeParts(instant, zone)`** — `{ isoWeekday, hour, minute,
|
|
32
|
+
minutesOfDay }` in the zone; what pricing windows / slot grids /
|
|
33
|
+
working-hours checks evaluate against (canonicalizes catalog's resolvers).
|
|
34
|
+
- **`CivilDate`** (branded `'YYYY-MM-DD'`) + `civilDate` / `isCivilDate` /
|
|
35
|
+
`civilDateOf(instant, zone)` / `civilDateToInstant(cd, zone, time?)`
|
|
36
|
+
(two-pass DST-safe) / `addCivilDays` / `civilDaysBetween` — the type for
|
|
37
|
+
hotel nights, POS business dates, VAT filing years. Lexicographic ==
|
|
38
|
+
chronological, so it's a range-queryable Mongo string key.
|
|
39
|
+
- **`isValidTimeZone(zone)`** — boot-time host-config validation;
|
|
40
|
+
**`listTimeZones()`** + **`zoneOffsetLabel(instant, zone)`** — the data
|
|
41
|
+
source + label for Google-Calendar-style zone pickers, straight from ICU
|
|
42
|
+
(`Intl.supportedValuesOf`), no bundled list to go stale.
|
|
43
|
+
- `TimeZoneError`; formatter instances cached per zone.
|
|
44
|
+
|
|
45
|
+
No arithmetic here (that stays in `/calendar`) — resolution only.
|
|
46
|
+
|
|
6
47
|
## [0.7.2] - 2026-05-30
|
|
7
48
|
|
|
8
49
|
### Added — `BankTransaction` per-account routing + pending lifecycle fields
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//#region src/scheduling/calendar.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Calendar boundaries + arithmetic — the ONE place the stack computes
|
|
4
|
+
* "start of day / month", weekday, and month/day/year addition, so no package
|
|
5
|
+
* hand-rolls it with server-LOCAL `Date` methods (`getFullYear`/`getMonth`/
|
|
6
|
+
* `getDate`/`getDay`), which silently shift with the deploy machine's `TZ` env.
|
|
7
|
+
*
|
|
8
|
+
* Every function is PURE and takes a fixed **UTC offset in minutes** (east of
|
|
9
|
+
* UTC — `360` = UTC+6 / Asia-Dhaka). Same convention as `cadence` / `sla-policy`:
|
|
10
|
+
* no IANA tz database, no DST. A fixed offset is exact for zones without DST
|
|
11
|
+
* (e.g. Bangladesh, India, most of Asia) and is what a "daily limit resets at
|
|
12
|
+
* local midnight" boundary needs. For DST-correct wall-clock math the host
|
|
13
|
+
* resolves the offset for the instant and passes it in.
|
|
14
|
+
*
|
|
15
|
+
* `Date` instants are UTC epoch and timezone-independent — these helpers only
|
|
16
|
+
* decide WHERE the day/month boundary falls, never how "now" is recorded.
|
|
17
|
+
*
|
|
18
|
+
* Resolving that offset from an IANA zone name is `/timezone`'s job — the
|
|
19
|
+
* two compose: `startOfMonth(instant, zoneOffsetMinutes(instant, zone))`.
|
|
20
|
+
* For a DST-exact DAY boundary prefer the civil-date round-trip from
|
|
21
|
+
* `/timezone`: `civilDateToInstant(civilDateOf(instant, zone), zone)`.
|
|
22
|
+
*/
|
|
23
|
+
/** Minutes east of UTC. `0` = UTC, `360` = UTC+6 (Asia/Dhaka), `-300` = UTC-5. */
|
|
24
|
+
type UtcOffsetMinutes = number;
|
|
25
|
+
/**
|
|
26
|
+
* The absolute instant of local midnight for the day CONTAINING `instant`, in
|
|
27
|
+
* the given offset. `startOfDay(2026-07-02T03:00Z, 360)` → `2026-07-01T18:00Z`
|
|
28
|
+
* (Dhaka midnight). With `offsetMinutes = 0` it's plain UTC midnight.
|
|
29
|
+
*/
|
|
30
|
+
declare function startOfDay(instant: Date, offsetMinutes?: UtcOffsetMinutes): Date;
|
|
31
|
+
/** The absolute instant of the first-of-month midnight, in the given offset. */
|
|
32
|
+
declare function startOfMonth(instant: Date, offsetMinutes?: UtcOffsetMinutes): Date;
|
|
33
|
+
/** ISO weekday (1 = Monday … 7 = Sunday) of `instant` in the given offset. */
|
|
34
|
+
declare function isoWeekday(instant: Date, offsetMinutes?: UtcOffsetMinutes): number;
|
|
35
|
+
/** Add whole days as an exact 24h × n duration (offset-agnostic, no DST skew). */
|
|
36
|
+
declare function addDays(instant: Date, days: number): Date;
|
|
37
|
+
/**
|
|
38
|
+
* Add calendar months in UTC, clamping the day so month-ends never overflow
|
|
39
|
+
* (`addMonths(Jan 31, 1)` → Feb 28/29). Use for subscription/access periods
|
|
40
|
+
* instead of local `setMonth(getMonth() + n)`.
|
|
41
|
+
*/
|
|
42
|
+
declare function addMonths(instant: Date, months: number): Date;
|
|
43
|
+
/** Add calendar years in UTC (clamps Feb 29 → Feb 28 on non-leap years). */
|
|
44
|
+
declare function addYears(instant: Date, years: number): Date;
|
|
45
|
+
//#endregion
|
|
46
|
+
export { UtcOffsetMinutes, addDays, addMonths, addYears, isoWeekday, startOfDay, startOfMonth };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/scheduling/calendar.ts
|
|
2
|
+
const MINUTE_MS = 6e4;
|
|
3
|
+
const DAY_MS = 864e5;
|
|
4
|
+
/**
|
|
5
|
+
* The absolute instant of local midnight for the day CONTAINING `instant`, in
|
|
6
|
+
* the given offset. `startOfDay(2026-07-02T03:00Z, 360)` → `2026-07-01T18:00Z`
|
|
7
|
+
* (Dhaka midnight). With `offsetMinutes = 0` it's plain UTC midnight.
|
|
8
|
+
*/
|
|
9
|
+
function startOfDay(instant, offsetMinutes = 0) {
|
|
10
|
+
const shifted = new Date(instant.getTime() + offsetMinutes * MINUTE_MS);
|
|
11
|
+
const localMidnightAsUtc = Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate());
|
|
12
|
+
return new Date(localMidnightAsUtc - offsetMinutes * MINUTE_MS);
|
|
13
|
+
}
|
|
14
|
+
/** The absolute instant of the first-of-month midnight, in the given offset. */
|
|
15
|
+
function startOfMonth(instant, offsetMinutes = 0) {
|
|
16
|
+
const shifted = new Date(instant.getTime() + offsetMinutes * MINUTE_MS);
|
|
17
|
+
const localFirstAsUtc = Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), 1);
|
|
18
|
+
return new Date(localFirstAsUtc - offsetMinutes * MINUTE_MS);
|
|
19
|
+
}
|
|
20
|
+
/** ISO weekday (1 = Monday … 7 = Sunday) of `instant` in the given offset. */
|
|
21
|
+
function isoWeekday(instant, offsetMinutes = 0) {
|
|
22
|
+
return (new Date(instant.getTime() + offsetMinutes * MINUTE_MS).getUTCDay() + 6) % 7 + 1;
|
|
23
|
+
}
|
|
24
|
+
/** Add whole days as an exact 24h × n duration (offset-agnostic, no DST skew). */
|
|
25
|
+
function addDays(instant, days) {
|
|
26
|
+
return new Date(instant.getTime() + days * DAY_MS);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Add calendar months in UTC, clamping the day so month-ends never overflow
|
|
30
|
+
* (`addMonths(Jan 31, 1)` → Feb 28/29). Use for subscription/access periods
|
|
31
|
+
* instead of local `setMonth(getMonth() + n)`.
|
|
32
|
+
*/
|
|
33
|
+
function addMonths(instant, months) {
|
|
34
|
+
const d = new Date(instant.getTime());
|
|
35
|
+
const day = d.getUTCDate();
|
|
36
|
+
d.setUTCDate(1);
|
|
37
|
+
d.setUTCMonth(d.getUTCMonth() + months);
|
|
38
|
+
const daysInTarget = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
|
|
39
|
+
d.setUTCDate(Math.min(day, daysInTarget));
|
|
40
|
+
return d;
|
|
41
|
+
}
|
|
42
|
+
/** Add calendar years in UTC (clamps Feb 29 → Feb 28 on non-leap years). */
|
|
43
|
+
function addYears(instant, years) {
|
|
44
|
+
return addMonths(instant, years * 12);
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { addDays, addMonths, addYears, isoWeekday, startOfDay, startOfMonth };
|
|
@@ -43,6 +43,15 @@ declare const CURRENCIES: {
|
|
|
43
43
|
* isn't in this table, {@link minorUnitFactor} returns 100 as the safe default.
|
|
44
44
|
*/
|
|
45
45
|
declare const MINOR_UNIT_FACTOR: Readonly<Record<string, number>>;
|
|
46
|
+
/**
|
|
47
|
+
* ISO 4217 format — 3 uppercase letters. Exported so consumers can embed
|
|
48
|
+
* the SAME pattern in JSON-Schema-representable validators (zod `.regex`,
|
|
49
|
+
* Mongoose `match`, OpenAPI `pattern`) instead of hand-rolling a copy:
|
|
50
|
+
* `.refine(isCurrencyCode)` validates at runtime but is dropped by
|
|
51
|
+
* `z.toJSONSchema`, losing the constraint from generated API docs.
|
|
52
|
+
* `isCurrencyCode` / `toCurrencyCode` remain the runtime API.
|
|
53
|
+
*/
|
|
54
|
+
declare const CURRENCY_PATTERN: RegExp;
|
|
46
55
|
/**
|
|
47
56
|
* Narrow a raw string to a {@link CurrencyCode}. Returns `null` if the string
|
|
48
57
|
* is not a valid ISO 4217 format (3 uppercase letters). This does not verify
|
|
@@ -107,4 +116,4 @@ declare function reverseWithSnapshot(baseAmount: number, fx: FxSnapshot): number
|
|
|
107
116
|
*/
|
|
108
117
|
declare function isFxSnapshot(value: unknown): value is FxSnapshot;
|
|
109
118
|
//#endregion
|
|
110
|
-
export {
|
|
119
|
+
export { MINOR_UNIT_FACTOR as a, isFxSnapshot as c, toCurrencyCode as d, FxSnapshot as i, minorUnitFactor as l, CURRENCY_PATTERN as n, convertWithSnapshot as o, CurrencyCode as r, isCurrencyCode as s, CURRENCIES as t, reverseWithSnapshot as u };
|
package/dist/currency.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
export { CURRENCIES, CurrencyCode, FxSnapshot, MINOR_UNIT_FACTOR, convertWithSnapshot, isCurrencyCode, isFxSnapshot, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };
|
|
1
|
+
import { a as MINOR_UNIT_FACTOR, c as isFxSnapshot, d as toCurrencyCode, i as FxSnapshot, l as minorUnitFactor, n as CURRENCY_PATTERN, o as convertWithSnapshot, r as CurrencyCode, s as isCurrencyCode, t as CURRENCIES, u as reverseWithSnapshot } from "./currency-BCJnJBlb.mjs";
|
|
2
|
+
export { CURRENCIES, CURRENCY_PATTERN, CurrencyCode, FxSnapshot, MINOR_UNIT_FACTOR, convertWithSnapshot, isCurrencyCode, isFxSnapshot, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };
|
package/dist/currency.mjs
CHANGED
|
@@ -61,6 +61,14 @@ const MINOR_UNIT_FACTOR = {
|
|
|
61
61
|
OMR: 1e3,
|
|
62
62
|
TND: 1e3
|
|
63
63
|
};
|
|
64
|
+
/**
|
|
65
|
+
* ISO 4217 format — 3 uppercase letters. Exported so consumers can embed
|
|
66
|
+
* the SAME pattern in JSON-Schema-representable validators (zod `.regex`,
|
|
67
|
+
* Mongoose `match`, OpenAPI `pattern`) instead of hand-rolling a copy:
|
|
68
|
+
* `.refine(isCurrencyCode)` validates at runtime but is dropped by
|
|
69
|
+
* `z.toJSONSchema`, losing the constraint from generated API docs.
|
|
70
|
+
* `isCurrencyCode` / `toCurrencyCode` remain the runtime API.
|
|
71
|
+
*/
|
|
64
72
|
const CURRENCY_PATTERN = /^[A-Z]{3}$/;
|
|
65
73
|
/**
|
|
66
74
|
* Narrow a raw string to a {@link CurrencyCode}. Returns `null` if the string
|
|
@@ -113,4 +121,4 @@ function isFxSnapshot(value) {
|
|
|
113
121
|
return typeof v.sourceCurrency === "string" && typeof v.baseCurrency === "string" && typeof v.rate === "number" && Number.isFinite(v.rate) && v.rate > 0 && v.snapshotAt instanceof Date;
|
|
114
122
|
}
|
|
115
123
|
//#endregion
|
|
116
|
-
export { CURRENCIES, MINOR_UNIT_FACTOR, convertWithSnapshot, isCurrencyCode, isFxSnapshot, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };
|
|
124
|
+
export { CURRENCIES, CURRENCY_PATTERN, MINOR_UNIT_FACTOR, convertWithSnapshot, isCurrencyCode, isFxSnapshot, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };
|
package/dist/money.d.mts
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { t as Brand } from "./brand-LLXqoh5J.mjs";
|
|
2
|
+
import { IsoWeekday } from "./cadence.mjs";
|
|
3
|
+
import { UtcOffsetMinutes } from "./calendar.mjs";
|
|
4
|
+
|
|
5
|
+
//#region src/scheduling/timezone.d.ts
|
|
6
|
+
declare class TimeZoneError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Is `zone` a valid IANA timezone name on this runtime's ICU data?
|
|
11
|
+
* Use at BOOT to validate host config (an offer's `timezone`, an org
|
|
12
|
+
* setting) — fail loud at wiring time, not on the first localized read.
|
|
13
|
+
*/
|
|
14
|
+
declare function isValidTimeZone(zone: string): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Every IANA timezone name this runtime supports — the data source for a
|
|
17
|
+
* Google-Calendar-style zone picker. Straight from ICU via
|
|
18
|
+
* `Intl.supportedValuesOf` (Node >= 18); no bundled tz list to go stale.
|
|
19
|
+
*
|
|
20
|
+
* UIs typically label each entry with its current offset:
|
|
21
|
+
* `listTimeZones().map((z) => ({ zone: z, label: zoneOffsetLabel(now, z) }))`.
|
|
22
|
+
*/
|
|
23
|
+
declare function listTimeZones(): readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* The zone's UTC offset AT `instant`, in minutes east of UTC — DST-exact.
|
|
26
|
+
* `zoneOffsetMinutes(july, 'Europe/London')` → 60; `(january, …)` → 0;
|
|
27
|
+
* `(any, 'Asia/Dhaka')` → 360.
|
|
28
|
+
*
|
|
29
|
+
* This is the value `/calendar`'s docstring tells hosts to "resolve and pass
|
|
30
|
+
* in": `startOfMonth(instant, zoneOffsetMinutes(instant, zone))`.
|
|
31
|
+
*/
|
|
32
|
+
declare function zoneOffsetMinutes(instant: Date, zone: string): UtcOffsetMinutes;
|
|
33
|
+
/**
|
|
34
|
+
* The zone's offset label AT `instant` — `'GMT+06:00'` / `'GMT-05:30'` /
|
|
35
|
+
* `'GMT'`. Ready-made display text for zone pickers.
|
|
36
|
+
*/
|
|
37
|
+
declare function zoneOffsetLabel(instant: Date, zone: string): string;
|
|
38
|
+
interface LocalTimeParts {
|
|
39
|
+
/** ISO weekday in the zone — 1=Mon … 7=Sun (matches `/cadence`). */
|
|
40
|
+
isoWeekday: IsoWeekday;
|
|
41
|
+
/** Hour of day in the zone, 0–23. */
|
|
42
|
+
hour: number;
|
|
43
|
+
/** Minute of hour, 0–59. */
|
|
44
|
+
minute: number;
|
|
45
|
+
/** `hour * 60 + minute` — the shape "HH:MM window" checks want. */
|
|
46
|
+
minutesOfDay: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The wall-clock weekday + time of `instant` in `zone` — what pricing
|
|
50
|
+
* schedules ("peak Fri 17:00–22:00"), slot grids, and working-hours checks
|
|
51
|
+
* evaluate against. DST-exact per instant.
|
|
52
|
+
*/
|
|
53
|
+
declare function localTimeParts(instant: Date, zone: string): LocalTimeParts;
|
|
54
|
+
/**
|
|
55
|
+
* A calendar day — `'YYYY-MM-DD'` — with NO time and NO zone until one is
|
|
56
|
+
* bound. The type for hotel nights, POS business dates, statement periods,
|
|
57
|
+
* VAT filing years. Lexicographic order == chronological order, so it's
|
|
58
|
+
* index- and range-query-friendly as a Mongo string key.
|
|
59
|
+
*/
|
|
60
|
+
type CivilDate = Brand<string, 'CivilDate'>;
|
|
61
|
+
/** Runtime guard for external input. */
|
|
62
|
+
declare function isCivilDate(value: unknown): value is CivilDate;
|
|
63
|
+
/** Validate + brand a `'YYYY-MM-DD'` string. Throws on malformed input. */
|
|
64
|
+
declare function civilDate(value: string): CivilDate;
|
|
65
|
+
/**
|
|
66
|
+
* The calendar day `instant` falls on IN `zone` — e.g. the POS business
|
|
67
|
+
* date: `civilDateOf(new Date(), 'Asia/Dhaka')`. Never depends on the
|
|
68
|
+
* server's TZ env.
|
|
69
|
+
*/
|
|
70
|
+
declare function civilDateOf(instant: Date, zone: string): CivilDate;
|
|
71
|
+
/**
|
|
72
|
+
* Bind a civil date (+ optional wall time) to a zone → the exact instant.
|
|
73
|
+
* `civilDateToInstant(night, hotelZone, { hour: 14 })` = check-in instant;
|
|
74
|
+
* with no `time`, local midnight — the DST-exact "start of that day in that
|
|
75
|
+
* zone".
|
|
76
|
+
*
|
|
77
|
+
* Two-pass offset resolution: the offset is looked up AT the candidate
|
|
78
|
+
* instant, then re-checked, so days where a DST transition sits between the
|
|
79
|
+
* wall time and the first guess still resolve correctly. Honest limit: a
|
|
80
|
+
* wall time that does not exist (inside a spring-forward gap) resolves to
|
|
81
|
+
* the instant the shifted clock reaches — the same choice Temporal's
|
|
82
|
+
* `disambiguation: 'compatible'` makes.
|
|
83
|
+
*/
|
|
84
|
+
declare function civilDateToInstant(cd: CivilDate, zone: string, time?: {
|
|
85
|
+
hour?: number;
|
|
86
|
+
minute?: number;
|
|
87
|
+
}): Date;
|
|
88
|
+
/**
|
|
89
|
+
* Pure calendar-day arithmetic — `addCivilDays('2026-02-28', 1)` →
|
|
90
|
+
* `'2026-03-01'`. Zone-free by definition (a civil date has no zone);
|
|
91
|
+
* use it to iterate hotel nights / statement days.
|
|
92
|
+
*/
|
|
93
|
+
declare function addCivilDays(cd: CivilDate, days: number): CivilDate;
|
|
94
|
+
/**
|
|
95
|
+
* Whole days from `a` to `b` (positive when `b` is later) — the NIGHTS
|
|
96
|
+
* count for a `[checkIn, checkOut)` stay expressed as civil dates.
|
|
97
|
+
*/
|
|
98
|
+
declare function civilDaysBetween(a: CivilDate, b: CivilDate): number;
|
|
99
|
+
//#endregion
|
|
100
|
+
export { CivilDate, LocalTimeParts, TimeZoneError, addCivilDays, civilDate, civilDateOf, civilDateToInstant, civilDaysBetween, isCivilDate, isValidTimeZone, listTimeZones, localTimeParts, zoneOffsetLabel, zoneOffsetMinutes };
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
//#region src/scheduling/timezone.ts
|
|
2
|
+
const MINUTE_MS = 6e4;
|
|
3
|
+
var TimeZoneError = class extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "TimeZoneError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
const offsetFormatters = /* @__PURE__ */ new Map();
|
|
10
|
+
const partsFormatters = /* @__PURE__ */ new Map();
|
|
11
|
+
const civilFormatters = /* @__PURE__ */ new Map();
|
|
12
|
+
function offsetFormatter(zone) {
|
|
13
|
+
let fmt = offsetFormatters.get(zone);
|
|
14
|
+
if (!fmt) {
|
|
15
|
+
fmt = new Intl.DateTimeFormat("en-US", {
|
|
16
|
+
timeZone: zone,
|
|
17
|
+
timeZoneName: "longOffset"
|
|
18
|
+
});
|
|
19
|
+
offsetFormatters.set(zone, fmt);
|
|
20
|
+
}
|
|
21
|
+
return fmt;
|
|
22
|
+
}
|
|
23
|
+
function partsFormatter(zone) {
|
|
24
|
+
let fmt = partsFormatters.get(zone);
|
|
25
|
+
if (!fmt) {
|
|
26
|
+
fmt = new Intl.DateTimeFormat("en-US", {
|
|
27
|
+
timeZone: zone,
|
|
28
|
+
weekday: "short",
|
|
29
|
+
hour: "2-digit",
|
|
30
|
+
minute: "2-digit",
|
|
31
|
+
hour12: false
|
|
32
|
+
});
|
|
33
|
+
partsFormatters.set(zone, fmt);
|
|
34
|
+
}
|
|
35
|
+
return fmt;
|
|
36
|
+
}
|
|
37
|
+
function civilFormatter(zone) {
|
|
38
|
+
let fmt = civilFormatters.get(zone);
|
|
39
|
+
if (!fmt) {
|
|
40
|
+
fmt = new Intl.DateTimeFormat("en-CA", {
|
|
41
|
+
timeZone: zone,
|
|
42
|
+
year: "numeric",
|
|
43
|
+
month: "2-digit",
|
|
44
|
+
day: "2-digit"
|
|
45
|
+
});
|
|
46
|
+
civilFormatters.set(zone, fmt);
|
|
47
|
+
}
|
|
48
|
+
return fmt;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Is `zone` a valid IANA timezone name on this runtime's ICU data?
|
|
52
|
+
* Use at BOOT to validate host config (an offer's `timezone`, an org
|
|
53
|
+
* setting) — fail loud at wiring time, not on the first localized read.
|
|
54
|
+
*/
|
|
55
|
+
function isValidTimeZone(zone) {
|
|
56
|
+
if (typeof zone !== "string" || zone.length === 0) return false;
|
|
57
|
+
try {
|
|
58
|
+
offsetFormatter(zone);
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
offsetFormatters.delete(zone);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
let cachedZones;
|
|
66
|
+
/**
|
|
67
|
+
* Every IANA timezone name this runtime supports — the data source for a
|
|
68
|
+
* Google-Calendar-style zone picker. Straight from ICU via
|
|
69
|
+
* `Intl.supportedValuesOf` (Node >= 18); no bundled tz list to go stale.
|
|
70
|
+
*
|
|
71
|
+
* UIs typically label each entry with its current offset:
|
|
72
|
+
* `listTimeZones().map((z) => ({ zone: z, label: zoneOffsetLabel(now, z) }))`.
|
|
73
|
+
*/
|
|
74
|
+
function listTimeZones() {
|
|
75
|
+
if (cachedZones) return cachedZones;
|
|
76
|
+
const intl = Intl;
|
|
77
|
+
if (typeof intl.supportedValuesOf !== "function") throw new TimeZoneError("Intl.supportedValuesOf is unavailable on this runtime (requires Node >= 18 / a modern browser). Upgrade the runtime or supply a static zone list.");
|
|
78
|
+
cachedZones = Object.freeze(intl.supportedValuesOf("timeZone"));
|
|
79
|
+
return cachedZones;
|
|
80
|
+
}
|
|
81
|
+
const OFFSET_RE = /GMT(?:([+-])(\d{2}):(\d{2}))?/;
|
|
82
|
+
/**
|
|
83
|
+
* The zone's UTC offset AT `instant`, in minutes east of UTC — DST-exact.
|
|
84
|
+
* `zoneOffsetMinutes(july, 'Europe/London')` → 60; `(january, …)` → 0;
|
|
85
|
+
* `(any, 'Asia/Dhaka')` → 360.
|
|
86
|
+
*
|
|
87
|
+
* This is the value `/calendar`'s docstring tells hosts to "resolve and pass
|
|
88
|
+
* in": `startOfMonth(instant, zoneOffsetMinutes(instant, zone))`.
|
|
89
|
+
*/
|
|
90
|
+
function zoneOffsetMinutes(instant, zone) {
|
|
91
|
+
const label = zoneOffsetLabel(instant, zone);
|
|
92
|
+
const m = OFFSET_RE.exec(label);
|
|
93
|
+
if (!m) throw new TimeZoneError(`Unparseable offset '${label}' for zone '${zone}'.`);
|
|
94
|
+
if (m[1] === void 0) return 0;
|
|
95
|
+
return (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The zone's offset label AT `instant` — `'GMT+06:00'` / `'GMT-05:30'` /
|
|
99
|
+
* `'GMT'`. Ready-made display text for zone pickers.
|
|
100
|
+
*/
|
|
101
|
+
function zoneOffsetLabel(instant, zone) {
|
|
102
|
+
let fmt;
|
|
103
|
+
try {
|
|
104
|
+
fmt = offsetFormatter(zone);
|
|
105
|
+
} catch {
|
|
106
|
+
offsetFormatters.delete(zone);
|
|
107
|
+
throw new TimeZoneError(`Invalid IANA timezone: '${zone}'.`);
|
|
108
|
+
}
|
|
109
|
+
const part = fmt.formatToParts(instant).find((p) => p.type === "timeZoneName");
|
|
110
|
+
if (!part) throw new TimeZoneError(`Runtime produced no offset part for zone '${zone}'.`);
|
|
111
|
+
return part.value;
|
|
112
|
+
}
|
|
113
|
+
const WEEKDAY_TO_ISO = {
|
|
114
|
+
Mon: 1,
|
|
115
|
+
Tue: 2,
|
|
116
|
+
Wed: 3,
|
|
117
|
+
Thu: 4,
|
|
118
|
+
Fri: 5,
|
|
119
|
+
Sat: 6,
|
|
120
|
+
Sun: 7
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* The wall-clock weekday + time of `instant` in `zone` — what pricing
|
|
124
|
+
* schedules ("peak Fri 17:00–22:00"), slot grids, and working-hours checks
|
|
125
|
+
* evaluate against. DST-exact per instant.
|
|
126
|
+
*/
|
|
127
|
+
function localTimeParts(instant, zone) {
|
|
128
|
+
let fmt;
|
|
129
|
+
try {
|
|
130
|
+
fmt = partsFormatter(zone);
|
|
131
|
+
} catch {
|
|
132
|
+
partsFormatters.delete(zone);
|
|
133
|
+
throw new TimeZoneError(`Invalid IANA timezone: '${zone}'.`);
|
|
134
|
+
}
|
|
135
|
+
let weekday;
|
|
136
|
+
let hour;
|
|
137
|
+
let minute;
|
|
138
|
+
for (const part of fmt.formatToParts(instant)) if (part.type === "weekday") weekday = part.value;
|
|
139
|
+
else if (part.type === "hour") hour = Number(part.value);
|
|
140
|
+
else if (part.type === "minute") minute = Number(part.value);
|
|
141
|
+
const isoWeekday = weekday !== void 0 ? WEEKDAY_TO_ISO[weekday] : void 0;
|
|
142
|
+
if (isoWeekday === void 0 || hour === void 0 || minute === void 0) throw new TimeZoneError(`Runtime produced incomplete wall parts for zone '${zone}'.`);
|
|
143
|
+
const h = hour === 24 ? 0 : hour;
|
|
144
|
+
return {
|
|
145
|
+
isoWeekday,
|
|
146
|
+
hour: h,
|
|
147
|
+
minute,
|
|
148
|
+
minutesOfDay: h * 60 + minute
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const CIVIL_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
152
|
+
/** Runtime guard for external input. */
|
|
153
|
+
function isCivilDate(value) {
|
|
154
|
+
if (typeof value !== "string") return false;
|
|
155
|
+
const m = CIVIL_RE.exec(value);
|
|
156
|
+
if (!m) return false;
|
|
157
|
+
const y = Number(m[1]);
|
|
158
|
+
const mo = Number(m[2]);
|
|
159
|
+
const d = Number(m[3]);
|
|
160
|
+
if (mo < 1 || mo > 12 || d < 1) return false;
|
|
161
|
+
return d <= new Date(Date.UTC(y, mo, 0)).getUTCDate();
|
|
162
|
+
}
|
|
163
|
+
/** Validate + brand a `'YYYY-MM-DD'` string. Throws on malformed input. */
|
|
164
|
+
function civilDate(value) {
|
|
165
|
+
if (!isCivilDate(value)) throw new TimeZoneError(`Invalid civil date '${value}' — expected 'YYYY-MM-DD'.`);
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The calendar day `instant` falls on IN `zone` — e.g. the POS business
|
|
170
|
+
* date: `civilDateOf(new Date(), 'Asia/Dhaka')`. Never depends on the
|
|
171
|
+
* server's TZ env.
|
|
172
|
+
*/
|
|
173
|
+
function civilDateOf(instant, zone) {
|
|
174
|
+
let fmt;
|
|
175
|
+
try {
|
|
176
|
+
fmt = civilFormatter(zone);
|
|
177
|
+
} catch {
|
|
178
|
+
civilFormatters.delete(zone);
|
|
179
|
+
throw new TimeZoneError(`Invalid IANA timezone: '${zone}'.`);
|
|
180
|
+
}
|
|
181
|
+
let y = "";
|
|
182
|
+
let m = "";
|
|
183
|
+
let d = "";
|
|
184
|
+
for (const part of fmt.formatToParts(instant)) if (part.type === "year") y = part.value;
|
|
185
|
+
else if (part.type === "month") m = part.value;
|
|
186
|
+
else if (part.type === "day") d = part.value;
|
|
187
|
+
return civilDate(`${y.padStart(4, "0")}-${m}-${d}`);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Bind a civil date (+ optional wall time) to a zone → the exact instant.
|
|
191
|
+
* `civilDateToInstant(night, hotelZone, { hour: 14 })` = check-in instant;
|
|
192
|
+
* with no `time`, local midnight — the DST-exact "start of that day in that
|
|
193
|
+
* zone".
|
|
194
|
+
*
|
|
195
|
+
* Two-pass offset resolution: the offset is looked up AT the candidate
|
|
196
|
+
* instant, then re-checked, so days where a DST transition sits between the
|
|
197
|
+
* wall time and the first guess still resolve correctly. Honest limit: a
|
|
198
|
+
* wall time that does not exist (inside a spring-forward gap) resolves to
|
|
199
|
+
* the instant the shifted clock reaches — the same choice Temporal's
|
|
200
|
+
* `disambiguation: 'compatible'` makes.
|
|
201
|
+
*/
|
|
202
|
+
function civilDateToInstant(cd, zone, time = {}) {
|
|
203
|
+
const m = CIVIL_RE.exec(cd);
|
|
204
|
+
if (!m) throw new TimeZoneError(`Invalid civil date '${cd}'.`);
|
|
205
|
+
const hour = time.hour ?? 0;
|
|
206
|
+
const minute = time.minute ?? 0;
|
|
207
|
+
const wallAsUtc = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), hour, minute);
|
|
208
|
+
const firstOffset = zoneOffsetMinutes(new Date(wallAsUtc), zone);
|
|
209
|
+
let instantMs = wallAsUtc - firstOffset * MINUTE_MS;
|
|
210
|
+
const refined = zoneOffsetMinutes(new Date(instantMs), zone);
|
|
211
|
+
if (refined !== firstOffset) instantMs = wallAsUtc - refined * MINUTE_MS;
|
|
212
|
+
return new Date(instantMs);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Pure calendar-day arithmetic — `addCivilDays('2026-02-28', 1)` →
|
|
216
|
+
* `'2026-03-01'`. Zone-free by definition (a civil date has no zone);
|
|
217
|
+
* use it to iterate hotel nights / statement days.
|
|
218
|
+
*/
|
|
219
|
+
function addCivilDays(cd, days) {
|
|
220
|
+
const m = CIVIL_RE.exec(cd);
|
|
221
|
+
if (!m) throw new TimeZoneError(`Invalid civil date '${cd}'.`);
|
|
222
|
+
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + days));
|
|
223
|
+
return civilDate(`${String(d.getUTCFullYear()).padStart(4, "0")}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Whole days from `a` to `b` (positive when `b` is later) — the NIGHTS
|
|
227
|
+
* count for a `[checkIn, checkOut)` stay expressed as civil dates.
|
|
228
|
+
*/
|
|
229
|
+
function civilDaysBetween(a, b) {
|
|
230
|
+
const pa = CIVIL_RE.exec(a);
|
|
231
|
+
const pb = CIVIL_RE.exec(b);
|
|
232
|
+
if (!pa || !pb) throw new TimeZoneError(`Invalid civil date '${!pa ? a : b}'.`);
|
|
233
|
+
const ma = Date.UTC(Number(pa[1]), Number(pa[2]) - 1, Number(pa[3]));
|
|
234
|
+
const mb = Date.UTC(Number(pb[1]), Number(pb[2]) - 1, Number(pb[3]));
|
|
235
|
+
return Math.round((mb - ma) / 864e5);
|
|
236
|
+
}
|
|
237
|
+
//#endregion
|
|
238
|
+
export { TimeZoneError, addCivilDays, civilDate, civilDateOf, civilDateToInstant, civilDaysBetween, isCivilDate, isValidTimeZone, listTimeZones, localTimeParts, zoneOffsetLabel, zoneOffsetMinutes };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
|
|
5
5
|
"author": "Classytic",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,14 @@
|
|
|
37
37
|
"types": "./dist/period.d.mts",
|
|
38
38
|
"default": "./dist/period.mjs"
|
|
39
39
|
},
|
|
40
|
+
"./calendar": {
|
|
41
|
+
"types": "./dist/calendar.d.mts",
|
|
42
|
+
"default": "./dist/calendar.mjs"
|
|
43
|
+
},
|
|
44
|
+
"./timezone": {
|
|
45
|
+
"types": "./dist/timezone.d.mts",
|
|
46
|
+
"default": "./dist/timezone.mjs"
|
|
47
|
+
},
|
|
40
48
|
"./reference": {
|
|
41
49
|
"types": "./dist/reference.d.mts",
|
|
42
50
|
"default": "./dist/reference.mjs"
|