@classytic/primitives 0.5.0 → 0.7.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 +91 -0
- package/dist/address.d.mts +1 -1
- package/dist/address.mjs +1 -1
- package/dist/approval.d.mts +1 -1
- package/dist/approval.mjs +1 -1
- package/dist/bank-transaction.d.mts +1 -1
- package/dist/{brand-DApaegoR.d.mts → brand-LLXqoh5J.d.mts} +1 -1
- package/dist/brand.d.mts +1 -1
- package/dist/cadence.d.mts +1 -1
- package/dist/cadence.mjs +1 -1
- package/dist/condition.d.mts +76 -0
- package/dist/condition.mjs +131 -0
- package/dist/context.d.mts +1 -1
- package/dist/{currency-rgUAn5KM.d.mts → currency-B4rSdN7D.d.mts} +2 -2
- package/dist/currency.d.mts +1 -1
- package/dist/currency.mjs +1 -1
- package/dist/events.d.mts +1 -1
- package/dist/events.mjs +1 -1
- package/dist/hold.d.mts +1 -1
- package/dist/hold.mjs +1 -1
- package/dist/mixin.d.mts +73 -0
- package/dist/mixin.mjs +47 -0
- package/dist/money.d.mts +2 -2
- package/dist/money.mjs +1 -1
- package/dist/outbox.d.mts +1 -1
- package/dist/outbox.mjs +1 -1
- package/dist/payment-allocation-status.d.mts +47 -0
- package/dist/payment-allocation-status.mjs +39 -0
- package/dist/payment-events.d.mts +141 -0
- package/dist/payment-events.mjs +16 -0
- package/dist/payment-gateway.d.mts +1 -1
- package/dist/payment-method-kind.d.mts +36 -0
- package/dist/payment-method-kind.mjs +31 -0
- package/dist/period.d.mts +1 -1
- package/dist/period.mjs +1 -1
- package/dist/person.d.mts +1 -1
- package/dist/person.mjs +1 -1
- package/dist/phone.d.mts +47 -0
- package/dist/phone.mjs +310 -0
- package/dist/reference.d.mts +1 -1
- package/dist/reference.mjs +1 -1
- package/dist/result.d.mts +1 -1
- package/dist/result.mjs +1 -1
- package/dist/sla-policy.d.mts +116 -0
- package/dist/sla-policy.mjs +120 -0
- package/dist/sla.d.mts +1 -1
- package/dist/sla.mjs +1 -1
- package/dist/split-allocation.d.mts +1 -1
- package/dist/split-allocation.mjs +1 -1
- package/dist/state-machine.d.mts +1 -1
- package/dist/state-machine.mjs +1 -1
- package/dist/status-history.d.mts +85 -0
- package/dist/status-history.mjs +48 -0
- package/package.json +33 -1
|
@@ -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
package/dist/sla.mjs
CHANGED
package/dist/state-machine.d.mts
CHANGED
package/dist/state-machine.mjs
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
//#region src/workflow/status-history.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Generic, immutable status-change log.
|
|
4
|
+
*
|
|
5
|
+
* Every domain aggregate that has a state machine (Order, Lead, Opportunity,
|
|
6
|
+
* Campaign, Subscription, Approval, Shipment, …) ends up reinventing the same
|
|
7
|
+
* triple: `{ status, occurredAt, by? }` + an append-only array on the entity.
|
|
8
|
+
* This module gives that pattern one canonical type and one zero-allocation
|
|
9
|
+
* helper, so domains stay focused on their *transitions* not the bookkeeping.
|
|
10
|
+
*
|
|
11
|
+
* Pairs naturally with `state-machine`: the machine decides if a transition
|
|
12
|
+
* is legal; this helper records the move that happened.
|
|
13
|
+
*
|
|
14
|
+
* Recording `durationInPriorMs` at write time means funnel-velocity reports
|
|
15
|
+
* (avg time spent in "Qualification") fall out of a single field instead of
|
|
16
|
+
* a re-walk over raw timestamps later.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* import { appendStatus, type StatusHistory } from '@classytic/primitives/status-history';
|
|
20
|
+
* import { defineStateMachine } from '@classytic/primitives/state-machine';
|
|
21
|
+
*
|
|
22
|
+
* type LeadStatus = 'new' | 'qualified' | 'converted' | 'disqualified';
|
|
23
|
+
* const MACHINE = defineStateMachine<LeadStatus>({ ... });
|
|
24
|
+
*
|
|
25
|
+
* function qualify(lead: Lead, by: string): Lead {
|
|
26
|
+
* MACHINE.assertTransition(lead.id, lead.status, 'qualified');
|
|
27
|
+
* return {
|
|
28
|
+
* ...lead,
|
|
29
|
+
* status: 'qualified',
|
|
30
|
+
* statusHistory: appendStatus(lead.statusHistory, 'qualified', { by }),
|
|
31
|
+
* };
|
|
32
|
+
* }
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* One row of the immutable history. Generic over the status enum so each
|
|
36
|
+
* domain stays type-safe (`StatusChangeEntry<LeadStatus>` rejects
|
|
37
|
+
* `'shipped'` at compile time).
|
|
38
|
+
*/
|
|
39
|
+
interface StatusChangeEntry<TStatus extends string> {
|
|
40
|
+
readonly status: TStatus;
|
|
41
|
+
readonly occurredAt: Date;
|
|
42
|
+
/** Who triggered the transition (user id, system actor name, etc.). */
|
|
43
|
+
readonly by?: string;
|
|
44
|
+
/** Free-form note — disqualify reason, comments, etc. */
|
|
45
|
+
readonly note?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Milliseconds spent in the previous status. `0` for the first entry.
|
|
48
|
+
* Computed at write time so downstream funnel-velocity / SLA aggregates
|
|
49
|
+
* never need to walk timestamps.
|
|
50
|
+
*/
|
|
51
|
+
readonly durationInPriorMs: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Convenience alias for entity fields: `statusHistory: StatusHistory<LeadStatus>`.
|
|
55
|
+
*/
|
|
56
|
+
type StatusHistory<TStatus extends string> = readonly StatusChangeEntry<TStatus>[];
|
|
57
|
+
interface AppendOptions {
|
|
58
|
+
by?: string;
|
|
59
|
+
note?: string;
|
|
60
|
+
/** Override the timestamp — useful for replay / migration. Defaults to `new Date()`. */
|
|
61
|
+
at?: Date;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Append a new status entry, computing `durationInPriorMs` from the last
|
|
65
|
+
* entry (or `0` for the first row). Returns a fresh array — never mutates.
|
|
66
|
+
*
|
|
67
|
+
* The previous-entry lookup is `O(1)` (last element), so this is safe to
|
|
68
|
+
* call from hot paths.
|
|
69
|
+
*/
|
|
70
|
+
declare function appendStatus<TStatus extends string>(history: StatusHistory<TStatus>, status: TStatus, options?: AppendOptions): StatusHistory<TStatus>;
|
|
71
|
+
/**
|
|
72
|
+
* Time spent in a status across the entire history. Used for funnel-velocity
|
|
73
|
+
* dashboards: "how long did this lead sit in `qualified` before it converted?".
|
|
74
|
+
*
|
|
75
|
+
* Counts duration on the entries that EXITED `status`. If the entity is
|
|
76
|
+
* currently in `status` and hasn't transitioned out, the still-elapsing time
|
|
77
|
+
* is added from `now`.
|
|
78
|
+
*/
|
|
79
|
+
declare function timeInStatus<TStatus extends string>(history: StatusHistory<TStatus>, status: TStatus, now?: Date): number;
|
|
80
|
+
/** Most recent entry, or `null` for an empty history. */
|
|
81
|
+
declare function latestEntry<TStatus extends string>(history: StatusHistory<TStatus>): StatusChangeEntry<TStatus> | null;
|
|
82
|
+
/** Most recent occurrence of `status`, or `null` if it never appeared. */
|
|
83
|
+
declare function lastTransitionTo<TStatus extends string>(history: StatusHistory<TStatus>, status: TStatus): StatusChangeEntry<TStatus> | null;
|
|
84
|
+
//#endregion
|
|
85
|
+
export { AppendOptions, StatusChangeEntry, StatusHistory, appendStatus, lastTransitionTo, latestEntry, timeInStatus };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
//#region src/workflow/status-history.ts
|
|
2
|
+
/**
|
|
3
|
+
* Append a new status entry, computing `durationInPriorMs` from the last
|
|
4
|
+
* entry (or `0` for the first row). Returns a fresh array — never mutates.
|
|
5
|
+
*
|
|
6
|
+
* The previous-entry lookup is `O(1)` (last element), so this is safe to
|
|
7
|
+
* call from hot paths.
|
|
8
|
+
*/
|
|
9
|
+
function appendStatus(history, status, options = {}) {
|
|
10
|
+
const now = options.at ?? /* @__PURE__ */ new Date();
|
|
11
|
+
const prior = history.length === 0 ? void 0 : history[history.length - 1];
|
|
12
|
+
const entry = {
|
|
13
|
+
status,
|
|
14
|
+
occurredAt: now,
|
|
15
|
+
durationInPriorMs: prior === void 0 ? 0 : Math.max(0, now.getTime() - prior.occurredAt.getTime()),
|
|
16
|
+
...options.by !== void 0 ? { by: options.by } : {},
|
|
17
|
+
...options.note !== void 0 ? { note: options.note } : {}
|
|
18
|
+
};
|
|
19
|
+
return [...history, entry];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Time spent in a status across the entire history. Used for funnel-velocity
|
|
23
|
+
* dashboards: "how long did this lead sit in `qualified` before it converted?".
|
|
24
|
+
*
|
|
25
|
+
* Counts duration on the entries that EXITED `status`. If the entity is
|
|
26
|
+
* currently in `status` and hasn't transitioned out, the still-elapsing time
|
|
27
|
+
* is added from `now`.
|
|
28
|
+
*/
|
|
29
|
+
function timeInStatus(history, status, now = /* @__PURE__ */ new Date()) {
|
|
30
|
+
let total = 0;
|
|
31
|
+
for (let i = 0; i < history.length; i++) {
|
|
32
|
+
const entry = history[i];
|
|
33
|
+
if (i > 0 && history[i - 1].status === status) total += entry.durationInPriorMs;
|
|
34
|
+
}
|
|
35
|
+
if (history.length > 0 && history[history.length - 1].status === status) total += Math.max(0, now.getTime() - history[history.length - 1].occurredAt.getTime());
|
|
36
|
+
return total;
|
|
37
|
+
}
|
|
38
|
+
/** Most recent entry, or `null` for an empty history. */
|
|
39
|
+
function latestEntry(history) {
|
|
40
|
+
return history.length === 0 ? null : history[history.length - 1];
|
|
41
|
+
}
|
|
42
|
+
/** Most recent occurrence of `status`, or `null` if it never appeared. */
|
|
43
|
+
function lastTransitionTo(history, status) {
|
|
44
|
+
for (let i = history.length - 1; i >= 0; i--) if (history[i].status === status) return history[i];
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
export { appendStatus, lastTransitionTo, latestEntry, timeInStatus };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -97,6 +97,38 @@
|
|
|
97
97
|
"types": "./dist/payment-gateway.d.mts",
|
|
98
98
|
"default": "./dist/payment-gateway.mjs"
|
|
99
99
|
},
|
|
100
|
+
"./payment-method-kind": {
|
|
101
|
+
"types": "./dist/payment-method-kind.d.mts",
|
|
102
|
+
"default": "./dist/payment-method-kind.mjs"
|
|
103
|
+
},
|
|
104
|
+
"./payment-allocation-status": {
|
|
105
|
+
"types": "./dist/payment-allocation-status.d.mts",
|
|
106
|
+
"default": "./dist/payment-allocation-status.mjs"
|
|
107
|
+
},
|
|
108
|
+
"./payment-events": {
|
|
109
|
+
"types": "./dist/payment-events.d.mts",
|
|
110
|
+
"default": "./dist/payment-events.mjs"
|
|
111
|
+
},
|
|
112
|
+
"./phone": {
|
|
113
|
+
"types": "./dist/phone.d.mts",
|
|
114
|
+
"default": "./dist/phone.mjs"
|
|
115
|
+
},
|
|
116
|
+
"./status-history": {
|
|
117
|
+
"types": "./dist/status-history.d.mts",
|
|
118
|
+
"default": "./dist/status-history.mjs"
|
|
119
|
+
},
|
|
120
|
+
"./condition": {
|
|
121
|
+
"types": "./dist/condition.d.mts",
|
|
122
|
+
"default": "./dist/condition.mjs"
|
|
123
|
+
},
|
|
124
|
+
"./mixin": {
|
|
125
|
+
"types": "./dist/mixin.d.mts",
|
|
126
|
+
"default": "./dist/mixin.mjs"
|
|
127
|
+
},
|
|
128
|
+
"./sla-policy": {
|
|
129
|
+
"types": "./dist/sla-policy.d.mts",
|
|
130
|
+
"default": "./dist/sla-policy.mjs"
|
|
131
|
+
},
|
|
100
132
|
"./package.json": "./package.json"
|
|
101
133
|
},
|
|
102
134
|
"files": [
|