@classytic/primitives 0.8.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -3,6 +3,35 @@
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.0] - 2026-07-02
7
+
8
+ ### Added — `/timezone`: IANA resolution + civil dates (the door for `/calendar`'s escape hatch)
9
+
10
+ `/calendar` owns wall-clock ARITHMETIC over a fixed `UtcOffsetMinutes` and
11
+ tells hosts to "resolve the offset for the instant and pass it in" — but the
12
+ stack had no shared resolver, so consumers hand-rolled `Intl.DateTimeFormat`
13
+ (three divergent copies found: catalog ×2, be-prod POS). `/timezone` is that
14
+ resolver — zero-dep (Intl IS the ICU tz database), Temporal-shaped names for
15
+ a future mechanical migration:
16
+
17
+ - **`zoneOffsetMinutes(instant, zone)`** — per-instant, DST-exact offset;
18
+ composes directly with `/calendar`: `startOfMonth(i, zoneOffsetMinutes(i, z))`.
19
+ - **`localTimeParts(instant, zone)`** — `{ isoWeekday, hour, minute,
20
+ minutesOfDay }` in the zone; what pricing windows / slot grids /
21
+ working-hours checks evaluate against (canonicalizes catalog's resolvers).
22
+ - **`CivilDate`** (branded `'YYYY-MM-DD'`) + `civilDate` / `isCivilDate` /
23
+ `civilDateOf(instant, zone)` / `civilDateToInstant(cd, zone, time?)`
24
+ (two-pass DST-safe) / `addCivilDays` / `civilDaysBetween` — the type for
25
+ hotel nights, POS business dates, VAT filing years. Lexicographic ==
26
+ chronological, so it's a range-queryable Mongo string key.
27
+ - **`isValidTimeZone(zone)`** — boot-time host-config validation;
28
+ **`listTimeZones()`** + **`zoneOffsetLabel(instant, zone)`** — the data
29
+ source + label for Google-Calendar-style zone pickers, straight from ICU
30
+ (`Intl.supportedValuesOf`), no bundled list to go stale.
31
+ - `TimeZoneError`; formatter instances cached per zone.
32
+
33
+ No arithmetic here (that stays in `/calendar`) — resolution only.
34
+
6
35
  ## [0.7.2] - 2026-05-30
7
36
 
8
37
  ### 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 };
@@ -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.8.0",
3
+ "version": "0.9.0",
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"