@classytic/primitives 0.7.2 → 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,114 @@
1
+ //#region src/scheduling/shelf-life.d.ts
2
+ /**
3
+ * Shelf-life / lot-tracking policy + pure expiry-date derivation.
4
+ *
5
+ * Describes whether a SKU is tracked by lot/serial, whether it carries an
6
+ * expiration date, and the day-offsets used to derive the four lifecycle
7
+ * dates of a freshly-received lot. The *policy* is product-level metadata
8
+ * (owned by a catalog); the *derived dates* are per-lot facts (owned by a
9
+ * WMS). This module is the shared contract both speak so the shape can't
10
+ * drift between packages.
11
+ *
12
+ * Modeled on Odoo's `product_expiry` addon, which puts `tracking` +
13
+ * `use_expiration_date` and four integer day-deltas on `product.template`,
14
+ * then computes four dates on each `stock.lot`:
15
+ *
16
+ * expiration = receivedAt + shelfLifeDays
17
+ * bestBefore = expiration − bestBeforeDays (still safe, starts deteriorating)
18
+ * removal = expiration − removalDays (stop selling — FEFO sorts on THIS)
19
+ * alert = expiration − alertDays (raise a reminder)
20
+ *
21
+ * Consumed by `@classytic/catalog` (variant tracking policy) and
22
+ * `@classytic/flow` (lot date derivation at receipt, FEFO, fresh-on-hand).
23
+ *
24
+ * Zero-dep, pure UTC day arithmetic — safe in unit tests with no clock.
25
+ *
26
+ * @example
27
+ * const policy: ShelfLifePolicy = {
28
+ * mode: 'lot',
29
+ * useExpiration: true,
30
+ * shelfLifeDays: 540, // 18-month medicine
31
+ * removalDays: 30, // pull 30 days before expiry
32
+ * alertDays: 60, // remind 60 days before expiry
33
+ * };
34
+ * deriveShelfLifeDates(new Date('2026-01-01T00:00:00Z'), policy);
35
+ * // → { expiresAt: 2027-06-25…, removalDate: 2027-05-26…, alertDate: 2027-04-26… }
36
+ */
37
+ /** How a SKU's stock identity is tracked. Mirrors Odoo `product.tracking`. */
38
+ type TrackingMode = 'none' | 'lot' | 'serial';
39
+ interface ShelfLifePolicy {
40
+ /** `'none'` = tracked by quantity only; `'lot'` = batch; `'serial'` = unique unit. */
41
+ readonly mode: TrackingMode;
42
+ /**
43
+ * When `true`, received lots carry an expiration date and the freshness
44
+ * lifecycle (best-before / removal / alert) applies. When `false`, the
45
+ * SKU may still be lot/serial tracked for traceability but has no expiry.
46
+ */
47
+ readonly useExpiration: boolean;
48
+ /**
49
+ * Total shelf life in days: `expiration = receivedAt + shelfLifeDays`.
50
+ * Omit to require a manual expiration date at receipt (no auto-derivation).
51
+ */
52
+ readonly shelfLifeDays?: number;
53
+ /** Days before expiration when goods start deteriorating (best-before / use date). */
54
+ readonly bestBeforeDays?: number;
55
+ /**
56
+ * Days before expiration when goods must be pulled from sellable stock.
57
+ * This is the FEFO sort key and the fresh-on-hand cutoff — NOT the
58
+ * expiration date itself.
59
+ */
60
+ readonly removalDays?: number;
61
+ /** Days before expiration when an expiry reminder should fire. */
62
+ readonly alertDays?: number;
63
+ }
64
+ /** The four lifecycle dates of a received lot. All optional — only what the policy can derive. */
65
+ interface ShelfLifeDates {
66
+ readonly expiresAt?: Date;
67
+ readonly bestBeforeDate?: Date;
68
+ readonly removalDate?: Date;
69
+ readonly alertDate?: Date;
70
+ }
71
+ interface DeriveOptions {
72
+ /**
73
+ * Explicit expiration date — overrides `shelfLifeDays` derivation (manual
74
+ * receiving, GS1-scanned expiry). The other three dates are then offset
75
+ * back from THIS date, matching Odoo's edit-expiration behavior.
76
+ */
77
+ readonly expiresAt?: Date;
78
+ }
79
+ type ShelfLifeErrorCode = 'INVALID_MODE' | 'INVALID_DAYS' | 'INVALID_RECEIVED_AT' | 'INVALID_EXPIRES_AT';
80
+ declare class ShelfLifeError extends Error {
81
+ readonly name = "ShelfLifeError";
82
+ readonly code: ShelfLifeErrorCode;
83
+ constructor(code: ShelfLifeErrorCode, message: string);
84
+ }
85
+ /** True when the policy requires lot OR serial identity (i.e. lots get created). */
86
+ declare function isTracked(policy: ShelfLifePolicy): boolean;
87
+ /** True when received lots carry an expiration date. */
88
+ declare function isExpiryTracked(policy: ShelfLifePolicy): boolean;
89
+ /** Validate a policy. Call before storing host-side. */
90
+ declare function validateShelfLifePolicy(policy: ShelfLifePolicy): void;
91
+ /**
92
+ * Derive the four lifecycle dates for a lot received at `receivedAt`.
93
+ *
94
+ * Returns an empty object when the policy does not track expiry (`mode:
95
+ * 'none'` or `useExpiration: false`) and no explicit `expiresAt` override is
96
+ * given. Each of the three sub-dates is emitted only when both an expiration
97
+ * date resolves AND the corresponding offset is present in the policy.
98
+ *
99
+ * Offsets are clamped to never produce a date before `receivedAt` (an offset
100
+ * larger than the shelf life would otherwise yield a removal date in the
101
+ * past on day one).
102
+ */
103
+ declare function deriveShelfLifeDates(receivedAt: Date, policy: ShelfLifePolicy, opts?: DeriveOptions): ShelfLifeDates;
104
+ type ShelfLifeStatus = 'none' | 'fresh' | 'alert' | 'removed' | 'expired';
105
+ /**
106
+ * Classify a lot's freshness as of `asOf`. `'removed'` means past its removal
107
+ * date (excluded from fresh-on-hand even though not yet expired); `'expired'`
108
+ * means past the hard expiration date. Returns `'none'` when no dates apply.
109
+ *
110
+ * Precedence (most severe first): expired > removed > alert > fresh.
111
+ */
112
+ declare function shelfLifeStatus(dates: ShelfLifeDates, asOf: Date): ShelfLifeStatus;
113
+ //#endregion
114
+ export { DeriveOptions, ShelfLifeDates, ShelfLifeError, ShelfLifeErrorCode, ShelfLifePolicy, ShelfLifeStatus, TrackingMode, deriveShelfLifeDates, isExpiryTracked, isTracked, shelfLifeStatus, validateShelfLifePolicy };
@@ -0,0 +1,88 @@
1
+ //#region src/scheduling/shelf-life.ts
2
+ var ShelfLifeError = class extends Error {
3
+ name = "ShelfLifeError";
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ }
9
+ };
10
+ const DAY_MS = 1440 * 60 * 1e3;
11
+ const TRACKING_MODES = [
12
+ "none",
13
+ "lot",
14
+ "serial"
15
+ ];
16
+ /** True when the policy requires lot OR serial identity (i.e. lots get created). */
17
+ function isTracked(policy) {
18
+ return policy.mode !== "none";
19
+ }
20
+ /** True when received lots carry an expiration date. */
21
+ function isExpiryTracked(policy) {
22
+ return policy.mode !== "none" && policy.useExpiration;
23
+ }
24
+ /** Validate a policy. Call before storing host-side. */
25
+ function validateShelfLifePolicy(policy) {
26
+ if (!TRACKING_MODES.includes(policy.mode)) throw new ShelfLifeError("INVALID_MODE", `mode must be one of ${TRACKING_MODES.join(", ")}, got ${String(policy.mode)}`);
27
+ for (const [key, val] of [
28
+ ["shelfLifeDays", policy.shelfLifeDays],
29
+ ["bestBeforeDays", policy.bestBeforeDays],
30
+ ["removalDays", policy.removalDays],
31
+ ["alertDays", policy.alertDays]
32
+ ]) {
33
+ if (val === void 0) continue;
34
+ if (!Number.isFinite(val) || val < 0) throw new ShelfLifeError("INVALID_DAYS", `${key} must be a non-negative number, got ${String(val)}`);
35
+ }
36
+ }
37
+ /**
38
+ * Derive the four lifecycle dates for a lot received at `receivedAt`.
39
+ *
40
+ * Returns an empty object when the policy does not track expiry (`mode:
41
+ * 'none'` or `useExpiration: false`) and no explicit `expiresAt` override is
42
+ * given. Each of the three sub-dates is emitted only when both an expiration
43
+ * date resolves AND the corresponding offset is present in the policy.
44
+ *
45
+ * Offsets are clamped to never produce a date before `receivedAt` (an offset
46
+ * larger than the shelf life would otherwise yield a removal date in the
47
+ * past on day one).
48
+ */
49
+ function deriveShelfLifeDates(receivedAt, policy, opts = {}) {
50
+ validateShelfLifePolicy(policy);
51
+ if (Number.isNaN(receivedAt.getTime())) throw new ShelfLifeError("INVALID_RECEIVED_AT", "receivedAt is not a valid Date");
52
+ if (opts.expiresAt && Number.isNaN(opts.expiresAt.getTime())) throw new ShelfLifeError("INVALID_EXPIRES_AT", "opts.expiresAt is not a valid Date");
53
+ let expiresAt;
54
+ if (opts.expiresAt) expiresAt = opts.expiresAt;
55
+ else if (isExpiryTracked(policy) && policy.shelfLifeDays !== void 0) expiresAt = addDays(receivedAt, policy.shelfLifeDays);
56
+ if (!expiresAt) return {};
57
+ const floor = receivedAt.getTime();
58
+ const offsetBack = (days) => days === void 0 ? void 0 : new Date(Math.max(floor, expiresAt.getTime() - days * DAY_MS));
59
+ return {
60
+ expiresAt,
61
+ ...defined("bestBeforeDate", offsetBack(policy.bestBeforeDays)),
62
+ ...defined("removalDate", offsetBack(policy.removalDays)),
63
+ ...defined("alertDate", offsetBack(policy.alertDays))
64
+ };
65
+ }
66
+ /**
67
+ * Classify a lot's freshness as of `asOf`. `'removed'` means past its removal
68
+ * date (excluded from fresh-on-hand even though not yet expired); `'expired'`
69
+ * means past the hard expiration date. Returns `'none'` when no dates apply.
70
+ *
71
+ * Precedence (most severe first): expired > removed > alert > fresh.
72
+ */
73
+ function shelfLifeStatus(dates, asOf) {
74
+ const now = asOf.getTime();
75
+ if (!dates.expiresAt && !dates.removalDate && !dates.alertDate) return "none";
76
+ if (dates.expiresAt && now >= dates.expiresAt.getTime()) return "expired";
77
+ if (dates.removalDate && now >= dates.removalDate.getTime()) return "removed";
78
+ if (dates.alertDate && now >= dates.alertDate.getTime()) return "alert";
79
+ return "fresh";
80
+ }
81
+ function addDays(d, days) {
82
+ return new Date(d.getTime() + days * DAY_MS);
83
+ }
84
+ function defined(key, val) {
85
+ return val === void 0 ? {} : { [key]: val };
86
+ }
87
+ //#endregion
88
+ export { ShelfLifeError, deriveShelfLifeDates, isExpiryTracked, isTracked, shelfLifeStatus, validateShelfLifePolicy };
@@ -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.7.2",
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"
@@ -81,6 +89,10 @@
81
89
  "types": "./dist/sla.d.mts",
82
90
  "default": "./dist/sla.mjs"
83
91
  },
92
+ "./shelf-life": {
93
+ "types": "./dist/shelf-life.d.mts",
94
+ "default": "./dist/shelf-life.mjs"
95
+ },
84
96
  "./person": {
85
97
  "types": "./dist/person.d.mts",
86
98
  "default": "./dist/person.mjs"