@classytic/primitives 0.4.0 → 0.6.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/dist/address.d.mts +1 -1
  3. package/dist/address.mjs +1 -1
  4. package/dist/approval.d.mts +1 -1
  5. package/dist/approval.mjs +1 -1
  6. package/dist/bank-transaction.d.mts +167 -0
  7. package/dist/bank-transaction.mjs +1 -0
  8. package/dist/{brand-DApaegoR.d.mts → brand-LLXqoh5J.d.mts} +1 -1
  9. package/dist/brand.d.mts +1 -1
  10. package/dist/cadence.d.mts +1 -1
  11. package/dist/cadence.mjs +1 -1
  12. package/dist/condition.d.mts +76 -0
  13. package/dist/condition.mjs +131 -0
  14. package/dist/context.d.mts +1 -1
  15. package/dist/currency-B4rSdN7D.d.mts +110 -0
  16. package/dist/currency.d.mts +1 -109
  17. package/dist/currency.mjs +1 -1
  18. package/dist/events.d.mts +1 -1
  19. package/dist/events.mjs +1 -1
  20. package/dist/hold.d.mts +1 -1
  21. package/dist/hold.mjs +1 -1
  22. package/dist/mixin.d.mts +73 -0
  23. package/dist/mixin.mjs +47 -0
  24. package/dist/money.d.mts +2 -2
  25. package/dist/money.mjs +1 -1
  26. package/dist/outbox.d.mts +1 -1
  27. package/dist/outbox.mjs +1 -1
  28. package/dist/payment-gateway.d.mts +168 -0
  29. package/dist/payment-gateway.mjs +1 -0
  30. package/dist/period.d.mts +1 -1
  31. package/dist/period.mjs +1 -1
  32. package/dist/person.d.mts +1 -1
  33. package/dist/person.mjs +1 -1
  34. package/dist/phone.d.mts +47 -0
  35. package/dist/phone.mjs +310 -0
  36. package/dist/reference.d.mts +1 -1
  37. package/dist/reference.mjs +1 -1
  38. package/dist/result.d.mts +1 -1
  39. package/dist/result.mjs +1 -1
  40. package/dist/sla-policy.d.mts +116 -0
  41. package/dist/sla-policy.mjs +120 -0
  42. package/dist/sla.d.mts +1 -1
  43. package/dist/sla.mjs +1 -1
  44. package/dist/split-allocation.d.mts +1 -1
  45. package/dist/split-allocation.mjs +1 -1
  46. package/dist/state-machine.d.mts +1 -1
  47. package/dist/state-machine.mjs +1 -1
  48. package/dist/status-history.d.mts +86 -0
  49. package/dist/status-history.mjs +48 -0
  50. package/package.json +29 -1
package/dist/phone.mjs ADDED
@@ -0,0 +1,310 @@
1
+ import { err, ok } from "./result.mjs";
2
+ //#region src/identity/phone.ts
3
+ /**
4
+ * Phone number value object.
5
+ *
6
+ * `PersonName` and `Address` are typed; phones have been a plain `string` in
7
+ * `ContactInfo` so far. This primitive closes that gap with a tiny,
8
+ * zero-dependency wrapper that:
9
+ * 1. Normalizes E.164 input (strips formatting, keeps the leading `+`).
10
+ * 2. Captures the country calling code as a discrete field for routing /
11
+ * regional analytics.
12
+ * 3. Surfaces parse failures via `Result<PhoneNumber, PhoneError>` so
13
+ * callers don't deal with thrown errors at value-object construction.
14
+ *
15
+ * Deliberately NOT a full libphonenumber dep — that ships ~500 KB of metadata
16
+ * and is per-locale. We accept E.164 input and validate format; deeper
17
+ * validation (region-of-issue, mobile-vs-landline) is the host's call to
18
+ * layer on top. This keeps `@classytic/primitives` zero-dep and tree-shakeable.
19
+ *
20
+ * @example
21
+ * import { parsePhone, formatPhone, type PhoneNumber } from '@classytic/primitives/phone';
22
+ *
23
+ * const parsed = parsePhone('+1 (415) 555-0182');
24
+ * if (parsed.ok) {
25
+ * formatPhone(parsed.value); // '+14155550182'
26
+ * parsed.value.callingCode; // '1'
27
+ * }
28
+ */
29
+ var PhoneError = class extends Error {
30
+ name = "PhoneError";
31
+ code;
32
+ constructor(code, message) {
33
+ super(message);
34
+ this.code = code;
35
+ }
36
+ };
37
+ /**
38
+ * E.164 country calling codes by length. We accept 1–3 digits and let the
39
+ * longest-prefix-match decide. Source: ITU-T E.164 assignments (snapshot —
40
+ * stable across decades).
41
+ *
42
+ * Kept inline (not exported) because it's an implementation detail of the
43
+ * parser. Length is the only thing we actually need for the split.
44
+ */
45
+ const E164_PREFIXES_3 = new Set([
46
+ "210",
47
+ "211",
48
+ "212",
49
+ "213",
50
+ "216",
51
+ "218",
52
+ "220",
53
+ "221",
54
+ "222",
55
+ "223",
56
+ "224",
57
+ "225",
58
+ "226",
59
+ "227",
60
+ "228",
61
+ "229",
62
+ "230",
63
+ "231",
64
+ "232",
65
+ "233",
66
+ "234",
67
+ "235",
68
+ "236",
69
+ "237",
70
+ "238",
71
+ "239",
72
+ "240",
73
+ "241",
74
+ "242",
75
+ "243",
76
+ "244",
77
+ "245",
78
+ "246",
79
+ "247",
80
+ "248",
81
+ "249",
82
+ "250",
83
+ "251",
84
+ "252",
85
+ "253",
86
+ "254",
87
+ "255",
88
+ "256",
89
+ "257",
90
+ "258",
91
+ "260",
92
+ "261",
93
+ "262",
94
+ "263",
95
+ "264",
96
+ "265",
97
+ "266",
98
+ "267",
99
+ "268",
100
+ "269",
101
+ "290",
102
+ "291",
103
+ "297",
104
+ "298",
105
+ "299",
106
+ "350",
107
+ "351",
108
+ "352",
109
+ "353",
110
+ "354",
111
+ "355",
112
+ "356",
113
+ "357",
114
+ "358",
115
+ "359",
116
+ "370",
117
+ "371",
118
+ "372",
119
+ "373",
120
+ "374",
121
+ "375",
122
+ "376",
123
+ "377",
124
+ "378",
125
+ "379",
126
+ "380",
127
+ "381",
128
+ "382",
129
+ "383",
130
+ "385",
131
+ "386",
132
+ "387",
133
+ "389",
134
+ "420",
135
+ "421",
136
+ "423",
137
+ "500",
138
+ "501",
139
+ "502",
140
+ "503",
141
+ "504",
142
+ "505",
143
+ "506",
144
+ "507",
145
+ "508",
146
+ "509",
147
+ "590",
148
+ "591",
149
+ "592",
150
+ "593",
151
+ "594",
152
+ "595",
153
+ "596",
154
+ "597",
155
+ "598",
156
+ "599",
157
+ "670",
158
+ "672",
159
+ "673",
160
+ "674",
161
+ "675",
162
+ "676",
163
+ "677",
164
+ "678",
165
+ "679",
166
+ "680",
167
+ "681",
168
+ "682",
169
+ "683",
170
+ "685",
171
+ "686",
172
+ "687",
173
+ "688",
174
+ "689",
175
+ "690",
176
+ "691",
177
+ "692",
178
+ "800",
179
+ "808",
180
+ "850",
181
+ "852",
182
+ "853",
183
+ "855",
184
+ "856",
185
+ "870",
186
+ "878",
187
+ "880",
188
+ "881",
189
+ "882",
190
+ "883",
191
+ "886",
192
+ "888",
193
+ "960",
194
+ "961",
195
+ "962",
196
+ "963",
197
+ "964",
198
+ "965",
199
+ "966",
200
+ "967",
201
+ "968",
202
+ "970",
203
+ "971",
204
+ "972",
205
+ "973",
206
+ "974",
207
+ "975",
208
+ "976",
209
+ "977",
210
+ "979",
211
+ "992",
212
+ "993",
213
+ "994",
214
+ "995",
215
+ "996",
216
+ "998"
217
+ ]);
218
+ const E164_PREFIXES_2 = new Set([
219
+ "20",
220
+ "27",
221
+ "30",
222
+ "31",
223
+ "32",
224
+ "33",
225
+ "34",
226
+ "36",
227
+ "39",
228
+ "40",
229
+ "41",
230
+ "43",
231
+ "44",
232
+ "45",
233
+ "46",
234
+ "47",
235
+ "48",
236
+ "49",
237
+ "51",
238
+ "52",
239
+ "53",
240
+ "54",
241
+ "55",
242
+ "56",
243
+ "57",
244
+ "58",
245
+ "60",
246
+ "61",
247
+ "62",
248
+ "63",
249
+ "64",
250
+ "65",
251
+ "66",
252
+ "81",
253
+ "82",
254
+ "84",
255
+ "86",
256
+ "90",
257
+ "91",
258
+ "92",
259
+ "93",
260
+ "94",
261
+ "95",
262
+ "98"
263
+ ]);
264
+ const E164_PREFIXES_1 = new Set(["1", "7"]);
265
+ /**
266
+ * Parse free-form phone input into a normalized `PhoneNumber`. Accepts:
267
+ * - `'+14155550182'` → e164 unchanged
268
+ * - `'+1 (415) 555-0182'` → strips spaces / punctuation
269
+ * - `'+1.415.555.0182'` → strips dots
270
+ *
271
+ * Rejects anything without a leading `+` — we don't guess the country.
272
+ * If the host knows the user's region, prepend the code before calling.
273
+ */
274
+ function parsePhone(input) {
275
+ if (typeof input !== "string" || input.trim().length === 0) return err(new PhoneError("EMPTY", "phone input is empty"));
276
+ const trimmed = input.trim();
277
+ if (!trimmed.startsWith("+")) return err(new PhoneError("MISSING_PLUS", `phone must be E.164 (start with '+'): got ${JSON.stringify(input)}`));
278
+ const digits = trimmed.slice(1).replace(/[^0-9]/g, "");
279
+ if (digits.length === 0) return err(new PhoneError("NOT_E164", "phone has no digits"));
280
+ if (digits.length > 15) return err(new PhoneError("TOO_LONG", `phone exceeds E.164 15-digit limit: ${digits.length} digits`));
281
+ if (digits.length < 7) return err(new PhoneError("TOO_SHORT", `phone too short to be E.164: ${digits.length} digits`));
282
+ const callingCode = E164_PREFIXES_3.has(digits.slice(0, 3)) ? digits.slice(0, 3) : E164_PREFIXES_2.has(digits.slice(0, 2)) ? digits.slice(0, 2) : E164_PREFIXES_1.has(digits.slice(0, 1)) ? digits.slice(0, 1) : null;
283
+ if (callingCode === null) return err(new PhoneError("NOT_E164", `unrecognized country calling code in ${JSON.stringify(input)}`));
284
+ return ok({
285
+ e164: `+${digits}`,
286
+ callingCode,
287
+ nationalNumber: digits.slice(callingCode.length)
288
+ });
289
+ }
290
+ /** Convenience: returns the E.164 string. Useful when piping to a transport. */
291
+ function formatPhone(phone) {
292
+ return phone.e164;
293
+ }
294
+ /**
295
+ * Best-effort national-format render: groups the national number into 3-digit
296
+ * blocks. Not locale-aware — for display polish, hosts should format per
297
+ * region. Useful as a default in admin UIs.
298
+ *
299
+ * @example formatNational(parsePhone('+14155550182').value!) → '+1 415 555 0182'
300
+ */
301
+ function formatNational(phone) {
302
+ const groups = phone.nationalNumber.match(/.{1,3}/g) ?? [];
303
+ return `+${phone.callingCode} ${groups.join(" ")}`.trim();
304
+ }
305
+ /** Type guard. */
306
+ function isPhoneNumber(value) {
307
+ return typeof value === "object" && value !== null && typeof value.e164 === "string" && typeof value.callingCode === "string" && typeof value.nationalNumber === "string" && value.e164.startsWith("+");
308
+ }
309
+ //#endregion
310
+ export { PhoneError, formatNational, formatPhone, isPhoneNumber, parsePhone };
@@ -1,4 +1,4 @@
1
- //#region src/reference.d.ts
1
+ //#region src/composition/reference.d.ts
2
2
  /**
3
3
  * Minimal structural type for any ID object with a `.toString()` method. Does
4
4
  * NOT depend on `mongoose` — `Types.ObjectId` satisfies this shape
@@ -1,4 +1,4 @@
1
- //#region src/reference.ts
1
+ //#region src/composition/reference.ts
2
2
  /** Coerce any {@link IdLike} to its canonical string form. */
3
3
  function idToString(id) {
4
4
  return typeof id === "string" ? id : id.toString();
package/dist/result.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/result.d.ts
1
+ //#region src/composition/result.d.ts
2
2
  /**
3
3
  * A discriminated-union result type — an alternative to throwing for expected
4
4
  * domain failures. Zero-cost: use it where failure is a first-class outcome
package/dist/result.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/result.ts
1
+ //#region src/composition/result.ts
2
2
  function ok(value) {
3
3
  return {
4
4
  ok: true,
@@ -0,0 +1,116 @@
1
+ import { IsoWeekday } from "./cadence.mjs";
2
+ import { SLA } from "./sla.mjs";
3
+
4
+ //#region src/scheduling/sla-policy.d.ts
5
+ /**
6
+ * Tier of severity. Hosts decide the keys ('urgent' / 'p0' / 'gold' / etc.) —
7
+ * keep them stable across the org so reports and routing stay consistent.
8
+ */
9
+ type PriorityKey = string;
10
+ interface PriorityRule {
11
+ /** Time allotted for the FIRST reply. */
12
+ readonly firstResponseMs: number;
13
+ /** Time allotted for EACH subsequent reply (after first response was made). */
14
+ readonly rollingResponseMs: number;
15
+ }
16
+ /**
17
+ * Working-hours window. Times are minutes-since-midnight in the org's
18
+ * timezone — same convention as `cadence` for ISO weekdays.
19
+ *
20
+ * For simple "always on" SLAs, omit this field entirely.
21
+ */
22
+ interface WorkingHours {
23
+ /** ISO weekdays when work happens (1=Mon … 7=Sun). */
24
+ readonly weekdays: readonly IsoWeekday[];
25
+ readonly startMinute: number;
26
+ readonly endMinute: number;
27
+ /**
28
+ * IANA timezone (e.g. `'Asia/Dhaka'`). Optional — pure math here is UTC,
29
+ * but the host can localize when displaying.
30
+ */
31
+ readonly timezone?: string;
32
+ /**
33
+ * Dates to skip entirely (holidays). Stored as YYYY-MM-DD strings so
34
+ * round-trips through JSON stay lossless.
35
+ */
36
+ readonly holidays?: readonly string[];
37
+ }
38
+ interface SLAPolicy {
39
+ readonly id?: string;
40
+ readonly name: string;
41
+ /** Map of priority → durations. Must include `defaultPriority`. */
42
+ readonly priorities: Readonly<Record<PriorityKey, PriorityRule>>;
43
+ readonly defaultPriority: PriorityKey;
44
+ /** Optional — omit for 24/7 SLAs. */
45
+ readonly workingHours?: WorkingHours;
46
+ /** Optional human label for dashboards. */
47
+ readonly label?: string;
48
+ }
49
+ type SLAPolicyErrorCode = 'EMPTY_PRIORITIES' | 'UNKNOWN_DEFAULT' | 'INVALID_DURATION' | 'INVALID_HOURS';
50
+ declare class SLAPolicyError extends Error {
51
+ readonly name = "SLAPolicyError";
52
+ readonly code: SLAPolicyErrorCode;
53
+ constructor(code: SLAPolicyErrorCode, message: string);
54
+ }
55
+ /** Validate + freeze. Use this when registering a policy at app boot. */
56
+ declare function defineSLAPolicy(spec: SLAPolicy): SLAPolicy;
57
+ /**
58
+ * Convert a `SLAPolicy + priority` into the simpler `SLA` value object the
59
+ * `./sla` module already knows how to evaluate. The host stores the policy
60
+ * once; per-entity it persists `{ slaPolicyId, priority, startedAt }` and
61
+ * derives the SLA on demand.
62
+ */
63
+ declare function deriveFirstResponseSLA(policy: SLAPolicy, priority?: PriorityKey): SLA;
64
+ declare function deriveRollingResponseSLA(policy: SLAPolicy, priority?: PriorityKey): SLA;
65
+ /**
66
+ * Materialized SLA state at evaluation time. Hosts persist the inputs
67
+ * (`startedAt`, `firstRespondedAt`, `lastRespondedAt`); the output is derived
68
+ * on read so changes to the policy take effect immediately.
69
+ */
70
+ type SLAStatusKind = 'FirstResponseDue' | 'FirstResponseFulfilled' | 'RollingResponseDue' | 'Failed';
71
+ interface SLAStatus {
72
+ readonly kind: SLAStatusKind;
73
+ readonly responseBy: Date;
74
+ readonly remainingMs: number;
75
+ readonly breached: boolean;
76
+ /** Which SLA was driving the deadline calculation. */
77
+ readonly underlying: SLA;
78
+ }
79
+ interface SLAInputs {
80
+ readonly priority?: PriorityKey;
81
+ readonly startedAt: Date;
82
+ /** Set when the first agent reply has been recorded. */
83
+ readonly firstRespondedAt: Date | null;
84
+ /**
85
+ * Set to the most recent agent reply that closed the rolling window.
86
+ * Falls back to `firstRespondedAt` when no rolling cycle has elapsed.
87
+ */
88
+ readonly lastRespondedAt: Date | null;
89
+ }
90
+ /**
91
+ * Compute current SLA status against a policy. Pure — no clock side effects
92
+ * beyond the explicit `now` argument.
93
+ *
94
+ * Decision table:
95
+ * - firstRespondedAt == null → FirstResponseDue/Failed against firstResponseMs
96
+ * - firstRespondedAt set, no further outstanding cycle → FirstResponseFulfilled
97
+ * - firstRespondedAt set + a rolling cycle has started → RollingResponseDue/Failed
98
+ *
99
+ * The "rolling cycle has started" half is host-driven: when the customer
100
+ * replies again, the host sets `lastRespondedAt` to the prior agent reply
101
+ * and the SLA clock resumes from there. Until that happens, the status is
102
+ * `FirstResponseFulfilled`.
103
+ */
104
+ declare function evaluateSLAStatus(policy: SLAPolicy, inputs: SLAInputs, now?: Date): SLAStatus;
105
+ /**
106
+ * Is `instant` within the policy's working hours? Returns `true` when the
107
+ * policy has no `workingHours` defined (always-on SLA).
108
+ *
109
+ * NOTE: math here is UTC-based for determinism — if the host needs the
110
+ * policy in local time, normalize `instant` to the policy's `timezone`
111
+ * before passing in. Bringing a tz lib into `@classytic/primitives` is a
112
+ * dep we don't want.
113
+ */
114
+ declare function isWithinWorkingHours(policy: SLAPolicy, instant: Date): boolean;
115
+ //#endregion
116
+ export { PriorityKey, PriorityRule, SLAInputs, SLAPolicy, SLAPolicyError, SLAPolicyErrorCode, SLAStatus, SLAStatusKind, WorkingHours, defineSLAPolicy, deriveFirstResponseSLA, deriveRollingResponseSLA, evaluateSLAStatus, isWithinWorkingHours };
@@ -0,0 +1,120 @@
1
+ import { isBreached, remainingMs } from "./sla.mjs";
2
+ //#region src/scheduling/sla-policy.ts
3
+ var SLAPolicyError = class extends Error {
4
+ name = "SLAPolicyError";
5
+ code;
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.code = code;
9
+ }
10
+ };
11
+ /** Validate + freeze. Use this when registering a policy at app boot. */
12
+ function defineSLAPolicy(spec) {
13
+ const keys = Object.keys(spec.priorities);
14
+ if (keys.length === 0) throw new SLAPolicyError("EMPTY_PRIORITIES", "priorities map is empty");
15
+ if (!keys.includes(spec.defaultPriority)) throw new SLAPolicyError("UNKNOWN_DEFAULT", `defaultPriority '${spec.defaultPriority}' is not present in priorities map`);
16
+ for (const [key, rule] of Object.entries(spec.priorities)) if (!Number.isFinite(rule.firstResponseMs) || rule.firstResponseMs <= 0 || !Number.isFinite(rule.rollingResponseMs) || rule.rollingResponseMs <= 0) throw new SLAPolicyError("INVALID_DURATION", `priority '${key}' has non-positive duration`);
17
+ if (spec.workingHours !== void 0) {
18
+ const wh = spec.workingHours;
19
+ if (wh.startMinute < 0 || wh.startMinute >= 1440 || wh.endMinute <= wh.startMinute || wh.endMinute > 1440 || wh.weekdays.length === 0) throw new SLAPolicyError("INVALID_HOURS", "workingHours bounds are invalid");
20
+ }
21
+ return spec;
22
+ }
23
+ /**
24
+ * Convert a `SLAPolicy + priority` into the simpler `SLA` value object the
25
+ * `./sla` module already knows how to evaluate. The host stores the policy
26
+ * once; per-entity it persists `{ slaPolicyId, priority, startedAt }` and
27
+ * derives the SLA on demand.
28
+ */
29
+ function deriveFirstResponseSLA(policy, priority) {
30
+ return {
31
+ targetDurationMs: priorityRule(policy, priority).firstResponseMs,
32
+ breachPolicy: "escalate",
33
+ label: `${policy.name} · first response · ${priority ?? policy.defaultPriority}`
34
+ };
35
+ }
36
+ function deriveRollingResponseSLA(policy, priority) {
37
+ return {
38
+ targetDurationMs: priorityRule(policy, priority).rollingResponseMs,
39
+ breachPolicy: "escalate",
40
+ label: `${policy.name} · rolling response · ${priority ?? policy.defaultPriority}`
41
+ };
42
+ }
43
+ function priorityRule(policy, priority) {
44
+ const key = priority ?? policy.defaultPriority;
45
+ const rule = policy.priorities[key] ?? policy.priorities[policy.defaultPriority];
46
+ if (rule === void 0) throw new SLAPolicyError("UNKNOWN_DEFAULT", `priority '${key}' not found and defaultPriority resolution failed`);
47
+ return rule;
48
+ }
49
+ /**
50
+ * Compute current SLA status against a policy. Pure — no clock side effects
51
+ * beyond the explicit `now` argument.
52
+ *
53
+ * Decision table:
54
+ * - firstRespondedAt == null → FirstResponseDue/Failed against firstResponseMs
55
+ * - firstRespondedAt set, no further outstanding cycle → FirstResponseFulfilled
56
+ * - firstRespondedAt set + a rolling cycle has started → RollingResponseDue/Failed
57
+ *
58
+ * The "rolling cycle has started" half is host-driven: when the customer
59
+ * replies again, the host sets `lastRespondedAt` to the prior agent reply
60
+ * and the SLA clock resumes from there. Until that happens, the status is
61
+ * `FirstResponseFulfilled`.
62
+ */
63
+ function evaluateSLAStatus(policy, inputs, now = /* @__PURE__ */ new Date()) {
64
+ if (inputs.firstRespondedAt === null) {
65
+ const sla = deriveFirstResponseSLA(policy, inputs.priority);
66
+ const responseBy = new Date(inputs.startedAt.getTime() + sla.targetDurationMs);
67
+ const breached = isBreached(sla, inputs.startedAt, now);
68
+ return {
69
+ kind: breached ? "Failed" : "FirstResponseDue",
70
+ responseBy,
71
+ remainingMs: remainingMs(sla, inputs.startedAt, now),
72
+ breached,
73
+ underlying: sla
74
+ };
75
+ }
76
+ const rollingBase = inputs.lastRespondedAt ?? inputs.firstRespondedAt;
77
+ if (rollingBase === inputs.firstRespondedAt && inputs.lastRespondedAt === null) {
78
+ const sla = deriveFirstResponseSLA(policy, inputs.priority);
79
+ return {
80
+ kind: "FirstResponseFulfilled",
81
+ responseBy: new Date(inputs.firstRespondedAt.getTime()),
82
+ remainingMs: 0,
83
+ breached: false,
84
+ underlying: sla
85
+ };
86
+ }
87
+ const sla = deriveRollingResponseSLA(policy, inputs.priority);
88
+ const responseBy = new Date(rollingBase.getTime() + sla.targetDurationMs);
89
+ const breached = isBreached(sla, rollingBase, now);
90
+ return {
91
+ kind: breached ? "Failed" : "RollingResponseDue",
92
+ responseBy,
93
+ remainingMs: remainingMs(sla, rollingBase, now),
94
+ breached,
95
+ underlying: sla
96
+ };
97
+ }
98
+ /**
99
+ * Is `instant` within the policy's working hours? Returns `true` when the
100
+ * policy has no `workingHours` defined (always-on SLA).
101
+ *
102
+ * NOTE: math here is UTC-based for determinism — if the host needs the
103
+ * policy in local time, normalize `instant` to the policy's `timezone`
104
+ * before passing in. Bringing a tz lib into `@classytic/primitives` is a
105
+ * dep we don't want.
106
+ */
107
+ function isWithinWorkingHours(policy, instant) {
108
+ if (!policy.workingHours) return true;
109
+ const wh = policy.workingHours;
110
+ const weekday = (instant.getUTCDay() + 6) % 7 + 1;
111
+ if (!wh.weekdays.includes(weekday)) return false;
112
+ if (wh.holidays !== void 0 && wh.holidays.length > 0) {
113
+ const isoDate = instant.toISOString().slice(0, 10);
114
+ if (wh.holidays.includes(isoDate)) return false;
115
+ }
116
+ const minuteOfDay = instant.getUTCHours() * 60 + instant.getUTCMinutes();
117
+ return minuteOfDay >= wh.startMinute && minuteOfDay < wh.endMinute;
118
+ }
119
+ //#endregion
120
+ export { SLAPolicyError, defineSLAPolicy, deriveFirstResponseSLA, deriveRollingResponseSLA, evaluateSLAStatus, isWithinWorkingHours };
package/dist/sla.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/sla.d.ts
1
+ //#region src/scheduling/sla.d.ts
2
2
  /**
3
3
  * Service-Level Agreement value object + breach-detection helpers.
4
4
  *
package/dist/sla.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/sla.ts
1
+ //#region src/scheduling/sla.ts
2
2
  var SLAError = class extends Error {
3
3
  name = "SLAError";
4
4
  code;
@@ -1,4 +1,4 @@
1
- //#region src/split-allocation.d.ts
1
+ //#region src/money/split-allocation.d.ts
2
2
  /**
3
3
  * Deterministic integer allocation across N subjects.
4
4
  *
@@ -1,4 +1,4 @@
1
- //#region src/split-allocation.ts
1
+ //#region src/money/split-allocation.ts
2
2
  var SplitAllocationError = class extends Error {
3
3
  name = "SplitAllocationError";
4
4
  code;
@@ -1,4 +1,4 @@
1
- //#region src/state-machine.d.ts
1
+ //#region src/workflow/state-machine.d.ts
2
2
  /**
3
3
  * Declarative state-machine primitive.
4
4
  *
@@ -1,4 +1,4 @@
1
- //#region src/state-machine.ts
1
+ //#region src/workflow/state-machine.ts
2
2
  /**
3
3
  * Declarative state-machine primitive.
4
4
  *