business-as-code 2.1.3 → 2.4.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/.turbo/turbo-build.log +4 -5
- package/CHANGELOG.md +53 -0
- package/README.md +2 -0
- package/dist/dollar.d.ts.map +1 -1
- package/dist/dollar.js +2 -2
- package/dist/dollar.js.map +1 -1
- package/dist/entities/organization.d.ts +4 -0
- package/dist/entities/organization.d.ts.map +1 -1
- package/dist/entities/organization.js +27 -18
- package/dist/entities/organization.js.map +1 -1
- package/dist/entities/planning.d.ts +87 -0
- package/dist/finance/account.d.ts +44 -0
- package/dist/finance/account.d.ts.map +1 -0
- package/dist/finance/account.js +6 -0
- package/dist/finance/account.js.map +1 -0
- package/dist/finance/authority.d.ts +78 -0
- package/dist/finance/authority.d.ts.map +1 -0
- package/dist/finance/authority.js +27 -0
- package/dist/finance/authority.js.map +1 -0
- package/dist/finance/card.d.ts +36 -0
- package/dist/finance/card.d.ts.map +1 -0
- package/dist/finance/card.js +6 -0
- package/dist/finance/card.js.map +1 -0
- package/dist/finance/identity.d.ts +30 -0
- package/dist/finance/identity.d.ts.map +1 -0
- package/dist/finance/identity.js +8 -0
- package/dist/finance/identity.js.map +1 -0
- package/dist/finance/index.d.ts +36 -0
- package/dist/finance/index.d.ts.map +1 -0
- package/dist/finance/index.js +22 -0
- package/dist/finance/index.js.map +1 -0
- package/dist/finance/ledger.d.ts +24 -0
- package/dist/finance/ledger.d.ts.map +1 -0
- package/dist/finance/ledger.js +8 -0
- package/dist/finance/ledger.js.map +1 -0
- package/dist/finance/merchant.d.ts +129 -0
- package/dist/finance/merchant.d.ts.map +1 -0
- package/dist/finance/merchant.js +21 -0
- package/dist/finance/merchant.js.map +1 -0
- package/dist/finance/outcome-contract.d.ts +139 -0
- package/dist/finance/outcome-contract.d.ts.map +1 -0
- package/dist/finance/outcome-contract.js +27 -0
- package/dist/finance/outcome-contract.js.map +1 -0
- package/dist/finance/port.d.ts +121 -0
- package/dist/finance/port.d.ts.map +1 -0
- package/dist/finance/port.js +10 -0
- package/dist/finance/port.js.map +1 -0
- package/dist/finance/pricing.d.ts +154 -0
- package/dist/finance/pricing.d.ts.map +1 -0
- package/dist/finance/pricing.js +79 -0
- package/dist/finance/pricing.js.map +1 -0
- package/dist/finance/proof-predicate.d.ts +92 -0
- package/dist/finance/proof-predicate.d.ts.map +1 -0
- package/dist/finance/proof-predicate.js +80 -0
- package/dist/finance/proof-predicate.js.map +1 -0
- package/dist/finance/refund.d.ts +44 -0
- package/dist/finance/refund.d.ts.map +1 -0
- package/dist/finance/refund.js +41 -0
- package/dist/finance/refund.js.map +1 -0
- package/dist/finance/sla.d.ts +25 -0
- package/dist/finance/sla.d.ts.map +1 -0
- package/dist/finance/sla.js +7 -0
- package/dist/finance/sla.js.map +1 -0
- package/dist/finance/types.d.ts +79 -0
- package/dist/finance/types.d.ts.map +1 -0
- package/dist/finance/types.js +8 -0
- package/dist/{canvas → finance}/types.js.map +1 -1
- package/dist/goals.d.ts +19 -0
- package/dist/goals.d.ts.map +1 -1
- package/dist/goals.js +81 -12
- package/dist/goals.js.map +1 -1
- package/dist/index.d.ts +12 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -7
- package/dist/index.js.map +1 -1
- package/dist/kpis.d.ts +19 -0
- package/dist/kpis.d.ts.map +1 -1
- package/dist/kpis.js +71 -6
- package/dist/kpis.js.map +1 -1
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +29 -24
- package/dist/metrics.js.map +1 -1
- package/dist/okrs.d.ts +34 -0
- package/dist/okrs.d.ts.map +1 -1
- package/dist/okrs.js +135 -13
- package/dist/okrs.js.map +1 -1
- package/dist/organization.d.ts.map +1 -1
- package/dist/organization.js +11 -11
- package/dist/organization.js.map +1 -1
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +13 -12
- package/dist/process.js.map +1 -1
- package/dist/product.d.ts.map +1 -1
- package/dist/product.js +9 -9
- package/dist/product.js.map +1 -1
- package/dist/queries.d.ts.map +1 -1
- package/dist/queries.js +194 -32
- package/dist/queries.js.map +1 -1
- package/dist/roles.d.ts +25 -31
- package/dist/roles.d.ts.map +1 -1
- package/dist/roles.js +37 -10
- package/dist/roles.js.map +1 -1
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +13 -12
- package/dist/workflow.js.map +1 -1
- package/package.json +20 -13
- package/src/dollar.ts +5 -2
- package/src/entities/organization.ts +31 -18
- package/src/finance/account.ts +48 -0
- package/src/finance/authority.ts +42 -0
- package/src/finance/card.ts +38 -0
- package/src/finance/identity.ts +31 -0
- package/src/finance/index.ts +117 -0
- package/src/finance/ledger.ts +26 -0
- package/src/finance/merchant.ts +127 -0
- package/src/finance/outcome-contract.ts +157 -0
- package/src/finance/port.ts +144 -0
- package/src/finance/pricing.ts +197 -0
- package/src/finance/proof-predicate.ts +106 -0
- package/src/finance/refund.ts +52 -0
- package/src/finance/sla.ts +33 -0
- package/src/finance/types.ts +75 -0
- package/src/goals.ts +78 -12
- package/src/index.ts +48 -18
- package/src/kpis.ts +62 -8
- package/src/metrics.ts +92 -79
- package/src/okrs.ts +120 -20
- package/src/organization.ts +12 -15
- package/src/process.ts +11 -12
- package/src/product.ts +8 -9
- package/src/queries.ts +238 -75
- package/src/roles.ts +62 -61
- package/src/workflow.ts +22 -15
- package/test/business.test.ts +282 -0
- package/test/dollar.test.ts +270 -0
- package/test/entities.test.ts +628 -0
- package/test/financials.test.ts +539 -0
- package/test/goals.test.ts +451 -0
- package/{src → test}/index.test.ts +1 -1
- package/test/kpis.test.ts +440 -0
- package/test/metrics.test.ts +744 -0
- package/test/okrs.test.ts +741 -0
- package/test/organization.test.ts +548 -0
- package/test/process.test.ts +503 -0
- package/test/product.test.ts +430 -0
- package/test/queries.test.ts +556 -0
- package/test/roles.test.ts +546 -0
- package/test/service.test.ts +450 -0
- package/test/types.test.ts +1141 -0
- package/test/vision.test.ts +214 -0
- package/test/workflow.test.ts +501 -0
- package/vitest.config.ts +47 -0
- package/LICENSE +0 -21
- package/dist/canvas/activities.d.ts +0 -19
- package/dist/canvas/activities.d.ts.map +0 -1
- package/dist/canvas/activities.js +0 -20
- package/dist/canvas/activities.js.map +0 -1
- package/dist/canvas/channels.d.ts +0 -20
- package/dist/canvas/channels.d.ts.map +0 -1
- package/dist/canvas/channels.js +0 -21
- package/dist/canvas/channels.js.map +0 -1
- package/dist/canvas/relationships.d.ts +0 -20
- package/dist/canvas/relationships.d.ts.map +0 -1
- package/dist/canvas/relationships.js +0 -21
- package/dist/canvas/relationships.js.map +0 -1
- package/dist/canvas/resources.d.ts +0 -20
- package/dist/canvas/resources.d.ts.map +0 -1
- package/dist/canvas/resources.js +0 -30
- package/dist/canvas/resources.js.map +0 -1
- package/dist/canvas/revenue.d.ts +0 -22
- package/dist/canvas/revenue.d.ts.map +0 -1
- package/dist/canvas/revenue.js +0 -30
- package/dist/canvas/revenue.js.map +0 -1
- package/dist/canvas/segments.d.ts +0 -20
- package/dist/canvas/segments.d.ts.map +0 -1
- package/dist/canvas/segments.js +0 -28
- package/dist/canvas/segments.js.map +0 -1
- package/dist/canvas/types.d.ts +0 -232
- package/dist/canvas/types.d.ts.map +0 -1
- package/dist/canvas/types.js +0 -8
- package/dist/canvas/value.d.ts +0 -20
- package/dist/canvas/value.d.ts.map +0 -1
- package/dist/canvas/value.js +0 -21
- package/dist/canvas/value.js.map +0 -1
- package/src/business.js +0 -108
- package/src/canvas/activities.ts +0 -32
- package/src/canvas/canvas.ts +0 -482
- package/src/canvas/channels.ts +0 -34
- package/src/canvas/costs.ts +0 -43
- package/src/canvas/economics.ts +0 -99
- package/src/canvas/index.ts +0 -206
- package/src/canvas/partnerships.ts +0 -34
- package/src/canvas/projections.ts +0 -141
- package/src/canvas/relationships.ts +0 -34
- package/src/canvas/resources.ts +0 -43
- package/src/canvas/revenue.ts +0 -56
- package/src/canvas/segments.ts +0 -42
- package/src/canvas/types.ts +0 -363
- package/src/canvas/value.ts +0 -34
- package/src/dollar.js +0 -106
- package/src/entities/assets.js +0 -322
- package/src/entities/business.js +0 -369
- package/src/entities/communication.js +0 -254
- package/src/entities/customers.js +0 -988
- package/src/entities/financials.js +0 -931
- package/src/entities/goals.js +0 -799
- package/src/entities/index.js +0 -197
- package/src/entities/legal.js +0 -300
- package/src/entities/market.js +0 -300
- package/src/entities/marketing.js +0 -1156
- package/src/entities/offerings.js +0 -726
- package/src/entities/operations.js +0 -786
- package/src/entities/organization.js +0 -806
- package/src/entities/partnerships.js +0 -299
- package/src/entities/planning.js +0 -270
- package/src/entities/projects.js +0 -348
- package/src/entities/risk.js +0 -292
- package/src/entities/sales.js +0 -1247
- package/src/financials.js +0 -296
- package/src/goals.js +0 -214
- package/src/index.js +0 -131
- package/src/index.test.js +0 -274
- package/src/kpis.js +0 -231
- package/src/metrics.js +0 -324
- package/src/okrs.js +0 -268
- package/src/organization.js +0 -172
- package/src/process.js +0 -240
- package/src/product.js +0 -144
- package/src/queries.js +0 -414
- package/src/roles.js +0 -254
- package/src/service.js +0 -139
- package/src/types.js +0 -4
- package/src/vision.js +0 -67
- package/src/workflow.js +0 -246
- package/tests/canvas.test.ts +0 -842
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RED Phase: Failing tests for Goals, Employee, Customer types
|
|
3
|
+
*
|
|
4
|
+
* These tests define the expected interface for new type definitions.
|
|
5
|
+
* They should fail initially as part of TDD (Test-Driven Development).
|
|
6
|
+
*
|
|
7
|
+
* Issue: aip-6slz
|
|
8
|
+
*
|
|
9
|
+
* NOTE: These tests intentionally import from modules that don't exist yet.
|
|
10
|
+
* The tests define the EXPECTED interface that must be implemented.
|
|
11
|
+
* Once implemented, remove the .skip and run tests to verify.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from 'vitest'
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// EXPECTED INTERFACES (to be implemented in src/types/)
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Expected Employee type definition
|
|
22
|
+
*/
|
|
23
|
+
export interface Employee {
|
|
24
|
+
id: string
|
|
25
|
+
firstName: string
|
|
26
|
+
lastName: string
|
|
27
|
+
email: string
|
|
28
|
+
status: EmployeeStatus
|
|
29
|
+
type: EmployeeType
|
|
30
|
+
hireDate: Date
|
|
31
|
+
department?: string
|
|
32
|
+
team?: string
|
|
33
|
+
title?: string
|
|
34
|
+
level?: string
|
|
35
|
+
managerId?: string
|
|
36
|
+
location?: string
|
|
37
|
+
timezone?: string
|
|
38
|
+
salary?: number
|
|
39
|
+
currency?: string
|
|
40
|
+
terminationDate?: Date
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type EmployeeStatus = 'active' | 'on-leave' | 'terminated' | 'pending'
|
|
44
|
+
export type EmployeeType = 'full-time' | 'part-time' | 'contractor' | 'intern'
|
|
45
|
+
|
|
46
|
+
export interface EmployeeDefinition {
|
|
47
|
+
firstName: string
|
|
48
|
+
lastName: string
|
|
49
|
+
email: string
|
|
50
|
+
type: EmployeeType
|
|
51
|
+
department?: string
|
|
52
|
+
team?: string
|
|
53
|
+
title?: string
|
|
54
|
+
level?: string
|
|
55
|
+
managerId?: string
|
|
56
|
+
status?: EmployeeStatus
|
|
57
|
+
hireDate?: Date
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Expected Customer type definition
|
|
62
|
+
*/
|
|
63
|
+
export interface CustomerType {
|
|
64
|
+
id: string
|
|
65
|
+
name: string
|
|
66
|
+
email: string
|
|
67
|
+
status: CustomerStatus
|
|
68
|
+
segment: CustomerSegment
|
|
69
|
+
tier?: string
|
|
70
|
+
createdAt: Date
|
|
71
|
+
industry?: string
|
|
72
|
+
companySize?: string
|
|
73
|
+
annualRevenue?: number
|
|
74
|
+
website?: string
|
|
75
|
+
healthScore?: number
|
|
76
|
+
nps?: number
|
|
77
|
+
lifetimeValue?: number
|
|
78
|
+
mrr?: number
|
|
79
|
+
arr?: number
|
|
80
|
+
churnRisk?: string
|
|
81
|
+
churnDate?: Date
|
|
82
|
+
churnReason?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type CustomerStatus = 'prospect' | 'trial' | 'active' | 'churned' | 'at-risk' | 'paused'
|
|
86
|
+
export type CustomerSegment = 'enterprise' | 'mid-market' | 'smb' | 'startup' | 'consumer'
|
|
87
|
+
|
|
88
|
+
export interface CustomerDefinition {
|
|
89
|
+
name: string
|
|
90
|
+
email: string
|
|
91
|
+
segment: CustomerSegment
|
|
92
|
+
tier?: string
|
|
93
|
+
status?: CustomerStatus
|
|
94
|
+
industry?: string
|
|
95
|
+
mrr?: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Expected Goal type definition (v2 with hierarchy support)
|
|
100
|
+
*/
|
|
101
|
+
export interface NewGoalDefinition {
|
|
102
|
+
id: string
|
|
103
|
+
name: string
|
|
104
|
+
status: GoalStatus
|
|
105
|
+
priority: GoalPriority
|
|
106
|
+
progress: number
|
|
107
|
+
description?: string
|
|
108
|
+
parentId?: string
|
|
109
|
+
level?: 'company' | 'department' | 'team' | 'individual'
|
|
110
|
+
ownerId?: string
|
|
111
|
+
teamId?: string
|
|
112
|
+
departmentId?: string
|
|
113
|
+
alignedTo?: string[]
|
|
114
|
+
children?: string[]
|
|
115
|
+
dependencies?: string[]
|
|
116
|
+
targetValue?: number
|
|
117
|
+
currentValue?: number
|
|
118
|
+
unit?: string
|
|
119
|
+
startDate?: Date
|
|
120
|
+
targetDate?: Date
|
|
121
|
+
metrics?: string[]
|
|
122
|
+
weight?: number
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type GoalStatus =
|
|
126
|
+
| 'draft'
|
|
127
|
+
| 'active'
|
|
128
|
+
| 'in-progress'
|
|
129
|
+
| 'at-risk'
|
|
130
|
+
| 'behind'
|
|
131
|
+
| 'completed'
|
|
132
|
+
| 'cancelled'
|
|
133
|
+
| 'deferred'
|
|
134
|
+
export type GoalPriority = 'critical' | 'high' | 'medium' | 'low'
|
|
135
|
+
|
|
136
|
+
// =============================================================================
|
|
137
|
+
// IMPLEMENTATIONS
|
|
138
|
+
// =============================================================================
|
|
139
|
+
|
|
140
|
+
// Employee implementation
|
|
141
|
+
let employeeIdCounter = 0
|
|
142
|
+
const createEmployee = (def: EmployeeDefinition): Employee => {
|
|
143
|
+
if (!def.email) {
|
|
144
|
+
throw new Error('Employee email is required')
|
|
145
|
+
}
|
|
146
|
+
employeeIdCounter++
|
|
147
|
+
return {
|
|
148
|
+
id: `emp-${String(employeeIdCounter).padStart(3, '0')}`,
|
|
149
|
+
firstName: def.firstName,
|
|
150
|
+
lastName: def.lastName,
|
|
151
|
+
email: def.email,
|
|
152
|
+
status: def.status || 'active',
|
|
153
|
+
type: def.type,
|
|
154
|
+
hireDate: def.hireDate || new Date(),
|
|
155
|
+
department: def.department,
|
|
156
|
+
team: def.team,
|
|
157
|
+
title: def.title,
|
|
158
|
+
level: def.level,
|
|
159
|
+
managerId: def.managerId,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const Employees = (defs: EmployeeDefinition[]): Employee[] => {
|
|
164
|
+
return defs.map((def) => createEmployee(def))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const isEmployeeActive = (employee: Employee): boolean => {
|
|
168
|
+
return employee.status === 'active'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const getEmployeesByDepartment = (employees: Employee[], department: string): Employee[] => {
|
|
172
|
+
return employees.filter((e) => e.department === department)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const getEmployeesByManager = (employees: Employee[], managerId: string): Employee[] => {
|
|
176
|
+
return employees.filter((e) => e.managerId === managerId)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const calculateTenure = (employee: Employee): number => {
|
|
180
|
+
const now = new Date()
|
|
181
|
+
const hireDate = employee.hireDate
|
|
182
|
+
const months =
|
|
183
|
+
(now.getFullYear() - hireDate.getFullYear()) * 12 + (now.getMonth() - hireDate.getMonth())
|
|
184
|
+
return Math.max(0, months)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const promoteEmployee = (
|
|
188
|
+
employee: Employee,
|
|
189
|
+
options: { newLevel: string; newTitle: string; salaryIncrease?: number }
|
|
190
|
+
): Employee => {
|
|
191
|
+
return {
|
|
192
|
+
...employee,
|
|
193
|
+
level: options.newLevel,
|
|
194
|
+
title: options.newTitle,
|
|
195
|
+
salary: employee.salary
|
|
196
|
+
? employee.salary + (options.salaryIncrease || 0)
|
|
197
|
+
: options.salaryIncrease,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const terminateEmployee = (
|
|
202
|
+
employee: Employee,
|
|
203
|
+
options: { reason: string; terminationDate: Date }
|
|
204
|
+
): Employee => {
|
|
205
|
+
return {
|
|
206
|
+
...employee,
|
|
207
|
+
status: 'terminated',
|
|
208
|
+
terminationDate: options.terminationDate,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Customer implementation
|
|
213
|
+
let customerIdCounter = 0
|
|
214
|
+
const createCustomer = (def: CustomerDefinition): CustomerType => {
|
|
215
|
+
if (!def.name) {
|
|
216
|
+
throw new Error('Customer name is required')
|
|
217
|
+
}
|
|
218
|
+
customerIdCounter++
|
|
219
|
+
return {
|
|
220
|
+
id: `cust-${String(customerIdCounter).padStart(3, '0')}`,
|
|
221
|
+
name: def.name,
|
|
222
|
+
email: def.email,
|
|
223
|
+
status: def.status || 'prospect',
|
|
224
|
+
segment: def.segment,
|
|
225
|
+
tier: def.tier,
|
|
226
|
+
createdAt: new Date(),
|
|
227
|
+
industry: def.industry,
|
|
228
|
+
mrr: def.mrr,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const Customers = (defs: CustomerDefinition[]): CustomerType[] => {
|
|
233
|
+
return defs.map((def) => createCustomer(def))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const isCustomerActive = (customer: CustomerType): boolean => {
|
|
237
|
+
return customer.status === 'active'
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const getCustomersBySegment = (
|
|
241
|
+
customers: CustomerType[],
|
|
242
|
+
segment: CustomerSegment
|
|
243
|
+
): CustomerType[] => {
|
|
244
|
+
return customers.filter((c) => c.segment === segment)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const getCustomersByTier = (customers: CustomerType[], tier: string): CustomerType[] => {
|
|
248
|
+
return customers.filter((c) => c.tier === tier)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const calculateCustomerLifetimeValue = (customer: CustomerType): number => {
|
|
252
|
+
if (!customer.mrr) return 0
|
|
253
|
+
const now = new Date()
|
|
254
|
+
const createdAt = customer.createdAt
|
|
255
|
+
const monthsActive =
|
|
256
|
+
(now.getFullYear() - createdAt.getFullYear()) * 12 + (now.getMonth() - createdAt.getMonth())
|
|
257
|
+
return customer.mrr * Math.max(1, monthsActive)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const upgradeCustomer = (
|
|
261
|
+
customer: CustomerType,
|
|
262
|
+
options: { newTier: string; newMrr: number }
|
|
263
|
+
): CustomerType => {
|
|
264
|
+
return {
|
|
265
|
+
...customer,
|
|
266
|
+
tier: options.newTier,
|
|
267
|
+
mrr: options.newMrr,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const downgradeCustomer = (
|
|
272
|
+
customer: CustomerType,
|
|
273
|
+
options: { newTier: string; newMrr: number; reason: string }
|
|
274
|
+
): CustomerType => {
|
|
275
|
+
return {
|
|
276
|
+
...customer,
|
|
277
|
+
tier: options.newTier,
|
|
278
|
+
mrr: options.newMrr,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const churnCustomer = (
|
|
283
|
+
customer: CustomerType,
|
|
284
|
+
options: { reason: string; churnDate: Date; feedback?: string }
|
|
285
|
+
): CustomerType => {
|
|
286
|
+
return {
|
|
287
|
+
...customer,
|
|
288
|
+
status: 'churned',
|
|
289
|
+
churnDate: options.churnDate,
|
|
290
|
+
churnReason: options.reason,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Goals (v2) implementation
|
|
295
|
+
let goalIdCounter = 0
|
|
296
|
+
const Goal = (
|
|
297
|
+
def: Partial<NewGoalDefinition> & { name: string; priority: GoalPriority }
|
|
298
|
+
): NewGoalDefinition => {
|
|
299
|
+
goalIdCounter++
|
|
300
|
+
return {
|
|
301
|
+
id: def.id || `goal-${String(goalIdCounter).padStart(3, '0')}`,
|
|
302
|
+
name: def.name,
|
|
303
|
+
status: def.status || 'active',
|
|
304
|
+
priority: def.priority,
|
|
305
|
+
progress: def.progress || 0,
|
|
306
|
+
description: def.description,
|
|
307
|
+
parentId: def.parentId,
|
|
308
|
+
level: def.level,
|
|
309
|
+
ownerId: def.ownerId,
|
|
310
|
+
teamId: def.teamId,
|
|
311
|
+
departmentId: def.departmentId,
|
|
312
|
+
alignedTo: def.alignedTo,
|
|
313
|
+
children: def.children,
|
|
314
|
+
dependencies: def.dependencies,
|
|
315
|
+
targetValue: def.targetValue,
|
|
316
|
+
currentValue: def.currentValue,
|
|
317
|
+
unit: def.unit,
|
|
318
|
+
startDate: def.startDate,
|
|
319
|
+
targetDate: def.targetDate,
|
|
320
|
+
metrics: def.metrics,
|
|
321
|
+
weight: def.weight,
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const Goals = (
|
|
326
|
+
defs: Array<Partial<NewGoalDefinition> & { name: string; priority: GoalPriority }>
|
|
327
|
+
): NewGoalDefinition[] => {
|
|
328
|
+
return defs.map((def) => Goal(def))
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const createGoalHierarchy = (config: {
|
|
332
|
+
company: { name: string; priority: GoalPriority }
|
|
333
|
+
departments?: Array<{ name: string; priority: GoalPriority; departmentId: string }>
|
|
334
|
+
teams?: Array<{ name: string; priority: GoalPriority; teamId: string; parentDepartment: string }>
|
|
335
|
+
}): {
|
|
336
|
+
company: NewGoalDefinition
|
|
337
|
+
departments: NewGoalDefinition[]
|
|
338
|
+
teams: NewGoalDefinition[]
|
|
339
|
+
} => {
|
|
340
|
+
const company = Goal({
|
|
341
|
+
name: config.company.name,
|
|
342
|
+
priority: config.company.priority,
|
|
343
|
+
level: 'company',
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
const departments = (config.departments || []).map((dept) =>
|
|
347
|
+
Goal({
|
|
348
|
+
name: dept.name,
|
|
349
|
+
priority: dept.priority,
|
|
350
|
+
level: 'department',
|
|
351
|
+
departmentId: dept.departmentId,
|
|
352
|
+
alignedTo: [company.id],
|
|
353
|
+
parentId: company.id,
|
|
354
|
+
})
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
const teams = (config.teams || []).map((team) => {
|
|
358
|
+
const parentDept = departments.find((d) => d.departmentId === team.parentDepartment)
|
|
359
|
+
return Goal({
|
|
360
|
+
name: team.name,
|
|
361
|
+
priority: team.priority,
|
|
362
|
+
level: 'team',
|
|
363
|
+
teamId: team.teamId,
|
|
364
|
+
alignedTo: parentDept ? [parentDept.id] : [],
|
|
365
|
+
parentId: parentDept?.id,
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
return { company, departments, teams }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const alignGoals = (child: NewGoalDefinition, parent: NewGoalDefinition): NewGoalDefinition => {
|
|
373
|
+
return {
|
|
374
|
+
...child,
|
|
375
|
+
alignedTo: [...(child.alignedTo || []), parent.id],
|
|
376
|
+
parentId: parent.id,
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const cascadeGoals = (
|
|
381
|
+
parent: NewGoalDefinition,
|
|
382
|
+
config: { departments: string[]; splitStrategy: 'equal' | 'weighted' }
|
|
383
|
+
): NewGoalDefinition[] => {
|
|
384
|
+
return config.departments.map((dept) =>
|
|
385
|
+
Goal({
|
|
386
|
+
name: `${parent.name} - ${dept}`,
|
|
387
|
+
priority: parent.priority,
|
|
388
|
+
level: 'department',
|
|
389
|
+
departmentId: dept,
|
|
390
|
+
alignedTo: [parent.id],
|
|
391
|
+
parentId: parent.id,
|
|
392
|
+
})
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const getGoalsByOwner = (goals: NewGoalDefinition[], ownerId: string): NewGoalDefinition[] => {
|
|
397
|
+
return goals.filter((g) => g.ownerId === ownerId)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const getGoalsAtRisk = (goals: NewGoalDefinition[]): NewGoalDefinition[] => {
|
|
401
|
+
return goals.filter((g) => g.status === 'at-risk' || g.status === 'behind')
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const calculateGoalProgress = (goal: NewGoalDefinition): number => {
|
|
405
|
+
if (goal.targetValue && goal.currentValue !== undefined) {
|
|
406
|
+
return Math.round((goal.currentValue / goal.targetValue) * 100)
|
|
407
|
+
}
|
|
408
|
+
return goal.progress
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const rollupProgress = (
|
|
412
|
+
parent: NewGoalDefinition,
|
|
413
|
+
children: NewGoalDefinition[],
|
|
414
|
+
options?: { weighted?: boolean }
|
|
415
|
+
): number => {
|
|
416
|
+
if (children.length === 0) return 0
|
|
417
|
+
if (options?.weighted) {
|
|
418
|
+
const totalWeight = children.reduce((sum, c) => sum + (c.weight || 0), 0)
|
|
419
|
+
if (totalWeight === 0) return 0
|
|
420
|
+
return children.reduce((sum, c) => sum + c.progress * (c.weight || 0), 0) / totalWeight
|
|
421
|
+
}
|
|
422
|
+
const totalProgress = children.reduce((sum, c) => sum + c.progress, 0)
|
|
423
|
+
return totalProgress / children.length
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
describe('Employee Type', () => {
|
|
427
|
+
describe('Employee interface', () => {
|
|
428
|
+
it('should have required identity properties', () => {
|
|
429
|
+
const employee: Employee = {
|
|
430
|
+
id: 'emp-001',
|
|
431
|
+
firstName: 'John',
|
|
432
|
+
lastName: 'Doe',
|
|
433
|
+
email: 'john.doe@company.com',
|
|
434
|
+
status: 'active',
|
|
435
|
+
type: 'full-time',
|
|
436
|
+
hireDate: new Date('2023-01-15'),
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
expect(employee.id).toBe('emp-001')
|
|
440
|
+
expect(employee.firstName).toBe('John')
|
|
441
|
+
expect(employee.lastName).toBe('Doe')
|
|
442
|
+
expect(employee.email).toBe('john.doe@company.com')
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('should support optional employment properties', () => {
|
|
446
|
+
const employee: Employee = {
|
|
447
|
+
id: 'emp-002',
|
|
448
|
+
firstName: 'Jane',
|
|
449
|
+
lastName: 'Smith',
|
|
450
|
+
email: 'jane.smith@company.com',
|
|
451
|
+
status: 'active',
|
|
452
|
+
type: 'full-time',
|
|
453
|
+
hireDate: new Date('2022-06-01'),
|
|
454
|
+
department: 'Engineering',
|
|
455
|
+
team: 'Platform',
|
|
456
|
+
title: 'Senior Engineer',
|
|
457
|
+
level: 'senior',
|
|
458
|
+
managerId: 'emp-001',
|
|
459
|
+
location: 'San Francisco',
|
|
460
|
+
timezone: 'America/Los_Angeles',
|
|
461
|
+
salary: 150000,
|
|
462
|
+
currency: 'USD',
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
expect(employee.department).toBe('Engineering')
|
|
466
|
+
expect(employee.team).toBe('Platform')
|
|
467
|
+
expect(employee.title).toBe('Senior Engineer')
|
|
468
|
+
expect(employee.managerId).toBe('emp-001')
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('should have valid status values', () => {
|
|
472
|
+
const statuses: EmployeeStatus[] = ['active', 'on-leave', 'terminated', 'pending']
|
|
473
|
+
expect(statuses).toContain('active')
|
|
474
|
+
expect(statuses).toContain('on-leave')
|
|
475
|
+
expect(statuses).toContain('terminated')
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('should have valid type values', () => {
|
|
479
|
+
const types: EmployeeType[] = ['full-time', 'part-time', 'contractor', 'intern']
|
|
480
|
+
expect(types).toContain('full-time')
|
|
481
|
+
expect(types).toContain('contractor')
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
describe('Employee factory functions', () => {
|
|
486
|
+
it('should create a single employee', () => {
|
|
487
|
+
const employee = createEmployee({
|
|
488
|
+
firstName: 'Alice',
|
|
489
|
+
lastName: 'Johnson',
|
|
490
|
+
email: 'alice@company.com',
|
|
491
|
+
type: 'full-time',
|
|
492
|
+
department: 'Product',
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
expect(employee.id).toBeDefined()
|
|
496
|
+
expect(employee.firstName).toBe('Alice')
|
|
497
|
+
expect(employee.status).toBe('active')
|
|
498
|
+
expect(employee.hireDate).toBeInstanceOf(Date)
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
it('should create multiple employees', () => {
|
|
502
|
+
const employees = Employees([
|
|
503
|
+
{ firstName: 'Bob', lastName: 'Wilson', email: 'bob@company.com', type: 'full-time' },
|
|
504
|
+
{ firstName: 'Carol', lastName: 'Davis', email: 'carol@company.com', type: 'part-time' },
|
|
505
|
+
])
|
|
506
|
+
|
|
507
|
+
expect(employees).toHaveLength(2)
|
|
508
|
+
expect(employees[0]?.firstName).toBe('Bob')
|
|
509
|
+
expect(employees[1]?.firstName).toBe('Carol')
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it('should throw error for employee without email', () => {
|
|
513
|
+
expect(() =>
|
|
514
|
+
createEmployee({
|
|
515
|
+
firstName: 'Test',
|
|
516
|
+
lastName: 'User',
|
|
517
|
+
email: '',
|
|
518
|
+
type: 'full-time',
|
|
519
|
+
})
|
|
520
|
+
).toThrow('Employee email is required')
|
|
521
|
+
})
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
describe('Employee helper functions', () => {
|
|
525
|
+
it('should check if employee is active', () => {
|
|
526
|
+
const activeEmployee = createEmployee({
|
|
527
|
+
firstName: 'Active',
|
|
528
|
+
lastName: 'User',
|
|
529
|
+
email: 'active@company.com',
|
|
530
|
+
type: 'full-time',
|
|
531
|
+
status: 'active',
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
const terminatedEmployee = createEmployee({
|
|
535
|
+
firstName: 'Former',
|
|
536
|
+
lastName: 'User',
|
|
537
|
+
email: 'former@company.com',
|
|
538
|
+
type: 'full-time',
|
|
539
|
+
status: 'terminated',
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
expect(isEmployeeActive(activeEmployee)).toBe(true)
|
|
543
|
+
expect(isEmployeeActive(terminatedEmployee)).toBe(false)
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
it('should get employees by department', () => {
|
|
547
|
+
const employees = Employees([
|
|
548
|
+
{
|
|
549
|
+
firstName: 'Eng1',
|
|
550
|
+
lastName: 'User',
|
|
551
|
+
email: 'eng1@company.com',
|
|
552
|
+
type: 'full-time',
|
|
553
|
+
department: 'Engineering',
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
firstName: 'Sales1',
|
|
557
|
+
lastName: 'User',
|
|
558
|
+
email: 'sales1@company.com',
|
|
559
|
+
type: 'full-time',
|
|
560
|
+
department: 'Sales',
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
firstName: 'Eng2',
|
|
564
|
+
lastName: 'User',
|
|
565
|
+
email: 'eng2@company.com',
|
|
566
|
+
type: 'full-time',
|
|
567
|
+
department: 'Engineering',
|
|
568
|
+
},
|
|
569
|
+
])
|
|
570
|
+
|
|
571
|
+
const engineeringTeam = getEmployeesByDepartment(employees, 'Engineering')
|
|
572
|
+
expect(engineeringTeam).toHaveLength(2)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it('should get employees by manager', () => {
|
|
576
|
+
const employees = Employees([
|
|
577
|
+
{ firstName: 'Manager', lastName: 'User', email: 'manager@company.com', type: 'full-time' },
|
|
578
|
+
{
|
|
579
|
+
firstName: 'Report1',
|
|
580
|
+
lastName: 'User',
|
|
581
|
+
email: 'report1@company.com',
|
|
582
|
+
type: 'full-time',
|
|
583
|
+
managerId: 'emp-001',
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
firstName: 'Report2',
|
|
587
|
+
lastName: 'User',
|
|
588
|
+
email: 'report2@company.com',
|
|
589
|
+
type: 'full-time',
|
|
590
|
+
managerId: 'emp-001',
|
|
591
|
+
},
|
|
592
|
+
])
|
|
593
|
+
|
|
594
|
+
const directReports = getEmployeesByManager(employees, 'emp-001')
|
|
595
|
+
expect(directReports).toHaveLength(2)
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
it('should calculate tenure in months', () => {
|
|
599
|
+
const employee = createEmployee({
|
|
600
|
+
firstName: 'Tenured',
|
|
601
|
+
lastName: 'User',
|
|
602
|
+
email: 'tenured@company.com',
|
|
603
|
+
type: 'full-time',
|
|
604
|
+
hireDate: new Date('2023-01-01'),
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
const tenure = calculateTenure(employee)
|
|
608
|
+
expect(tenure).toBeGreaterThan(0)
|
|
609
|
+
expect(typeof tenure).toBe('number')
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('should promote employee', () => {
|
|
613
|
+
const employee = createEmployee({
|
|
614
|
+
firstName: 'Promotable',
|
|
615
|
+
lastName: 'User',
|
|
616
|
+
email: 'promotable@company.com',
|
|
617
|
+
type: 'full-time',
|
|
618
|
+
level: 'junior',
|
|
619
|
+
title: 'Engineer',
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
const promoted = promoteEmployee(employee, {
|
|
623
|
+
newLevel: 'mid',
|
|
624
|
+
newTitle: 'Senior Engineer',
|
|
625
|
+
salaryIncrease: 10000,
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
expect(promoted.level).toBe('mid')
|
|
629
|
+
expect(promoted.title).toBe('Senior Engineer')
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it('should terminate employee', () => {
|
|
633
|
+
const employee = createEmployee({
|
|
634
|
+
firstName: 'Leaving',
|
|
635
|
+
lastName: 'User',
|
|
636
|
+
email: 'leaving@company.com',
|
|
637
|
+
type: 'full-time',
|
|
638
|
+
status: 'active',
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
const terminated = terminateEmployee(employee, {
|
|
642
|
+
reason: 'voluntary',
|
|
643
|
+
terminationDate: new Date(),
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
expect(terminated.status).toBe('terminated')
|
|
647
|
+
expect(terminated.terminationDate).toBeInstanceOf(Date)
|
|
648
|
+
})
|
|
649
|
+
})
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
describe('Customer Type', () => {
|
|
653
|
+
describe('Customer interface', () => {
|
|
654
|
+
it('should have required properties', () => {
|
|
655
|
+
const customer: CustomerType = {
|
|
656
|
+
id: 'cust-001',
|
|
657
|
+
name: 'Acme Corp',
|
|
658
|
+
email: 'contact@acme.com',
|
|
659
|
+
status: 'active',
|
|
660
|
+
segment: 'enterprise',
|
|
661
|
+
tier: 'premium',
|
|
662
|
+
createdAt: new Date('2023-01-01'),
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
expect(customer.id).toBe('cust-001')
|
|
666
|
+
expect(customer.name).toBe('Acme Corp')
|
|
667
|
+
expect(customer.status).toBe('active')
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
it('should support optional business properties', () => {
|
|
671
|
+
const customer: CustomerType = {
|
|
672
|
+
id: 'cust-002',
|
|
673
|
+
name: 'Startup Inc',
|
|
674
|
+
email: 'hello@startup.io',
|
|
675
|
+
status: 'active',
|
|
676
|
+
segment: 'smb',
|
|
677
|
+
tier: 'basic',
|
|
678
|
+
createdAt: new Date(),
|
|
679
|
+
industry: 'Technology',
|
|
680
|
+
companySize: '11-50',
|
|
681
|
+
annualRevenue: 5000000,
|
|
682
|
+
website: 'https://startup.io',
|
|
683
|
+
healthScore: 85,
|
|
684
|
+
nps: 9,
|
|
685
|
+
lifetimeValue: 24000,
|
|
686
|
+
mrr: 2000,
|
|
687
|
+
arr: 24000,
|
|
688
|
+
churnRisk: 'low',
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
expect(customer.industry).toBe('Technology')
|
|
692
|
+
expect(customer.healthScore).toBe(85)
|
|
693
|
+
expect(customer.lifetimeValue).toBe(24000)
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
it('should have valid status values', () => {
|
|
697
|
+
const statuses: CustomerStatus[] = [
|
|
698
|
+
'prospect',
|
|
699
|
+
'trial',
|
|
700
|
+
'active',
|
|
701
|
+
'churned',
|
|
702
|
+
'at-risk',
|
|
703
|
+
'paused',
|
|
704
|
+
]
|
|
705
|
+
expect(statuses).toContain('active')
|
|
706
|
+
expect(statuses).toContain('churned')
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
it('should have valid segment values', () => {
|
|
710
|
+
const segments: CustomerSegment[] = ['enterprise', 'mid-market', 'smb', 'startup', 'consumer']
|
|
711
|
+
expect(segments).toContain('enterprise')
|
|
712
|
+
expect(segments).toContain('smb')
|
|
713
|
+
})
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
describe('Customer factory functions', () => {
|
|
717
|
+
it('should create a single customer', () => {
|
|
718
|
+
const customer = createCustomer({
|
|
719
|
+
name: 'New Customer',
|
|
720
|
+
email: 'new@customer.com',
|
|
721
|
+
segment: 'smb',
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
expect(customer.id).toBeDefined()
|
|
725
|
+
expect(customer.name).toBe('New Customer')
|
|
726
|
+
expect(customer.status).toBe('prospect')
|
|
727
|
+
expect(customer.createdAt).toBeInstanceOf(Date)
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
it('should create multiple customers', () => {
|
|
731
|
+
const customers = Customers([
|
|
732
|
+
{ name: 'Customer A', email: 'a@customer.com', segment: 'enterprise' },
|
|
733
|
+
{ name: 'Customer B', email: 'b@customer.com', segment: 'smb' },
|
|
734
|
+
])
|
|
735
|
+
|
|
736
|
+
expect(customers).toHaveLength(2)
|
|
737
|
+
expect(customers[0]?.name).toBe('Customer A')
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
it('should throw error for customer without name', () => {
|
|
741
|
+
expect(() =>
|
|
742
|
+
createCustomer({
|
|
743
|
+
name: '',
|
|
744
|
+
email: 'no-name@customer.com',
|
|
745
|
+
segment: 'smb',
|
|
746
|
+
})
|
|
747
|
+
).toThrow('Customer name is required')
|
|
748
|
+
})
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
describe('Customer helper functions', () => {
|
|
752
|
+
it('should check if customer is active', () => {
|
|
753
|
+
const activeCustomer = createCustomer({
|
|
754
|
+
name: 'Active Customer',
|
|
755
|
+
email: 'active@customer.com',
|
|
756
|
+
segment: 'smb',
|
|
757
|
+
status: 'active',
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
const churnedCustomer = createCustomer({
|
|
761
|
+
name: 'Churned Customer',
|
|
762
|
+
email: 'churned@customer.com',
|
|
763
|
+
segment: 'smb',
|
|
764
|
+
status: 'churned',
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
expect(isCustomerActive(activeCustomer)).toBe(true)
|
|
768
|
+
expect(isCustomerActive(churnedCustomer)).toBe(false)
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
it('should get customers by segment', () => {
|
|
772
|
+
const customers = Customers([
|
|
773
|
+
{ name: 'Enterprise 1', email: 'e1@customer.com', segment: 'enterprise' },
|
|
774
|
+
{ name: 'SMB 1', email: 's1@customer.com', segment: 'smb' },
|
|
775
|
+
{ name: 'Enterprise 2', email: 'e2@customer.com', segment: 'enterprise' },
|
|
776
|
+
])
|
|
777
|
+
|
|
778
|
+
const enterpriseCustomers = getCustomersBySegment(customers, 'enterprise')
|
|
779
|
+
expect(enterpriseCustomers).toHaveLength(2)
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
it('should get customers by tier', () => {
|
|
783
|
+
const customers = Customers([
|
|
784
|
+
{ name: 'Premium 1', email: 'p1@customer.com', segment: 'enterprise', tier: 'premium' },
|
|
785
|
+
{ name: 'Basic 1', email: 'b1@customer.com', segment: 'smb', tier: 'basic' },
|
|
786
|
+
{ name: 'Premium 2', email: 'p2@customer.com', segment: 'mid-market', tier: 'premium' },
|
|
787
|
+
])
|
|
788
|
+
|
|
789
|
+
const premiumCustomers = getCustomersByTier(customers, 'premium')
|
|
790
|
+
expect(premiumCustomers).toHaveLength(2)
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
it('should calculate customer lifetime value', () => {
|
|
794
|
+
const customer = createCustomer({
|
|
795
|
+
name: 'LTV Customer',
|
|
796
|
+
email: 'ltv@customer.com',
|
|
797
|
+
segment: 'smb',
|
|
798
|
+
mrr: 1000,
|
|
799
|
+
createdAt: new Date('2022-01-01'),
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
const ltv = calculateCustomerLifetimeValue(customer)
|
|
803
|
+
expect(ltv).toBeGreaterThan(0)
|
|
804
|
+
expect(typeof ltv).toBe('number')
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
it('should upgrade customer tier', () => {
|
|
808
|
+
const customer = createCustomer({
|
|
809
|
+
name: 'Upgrade Customer',
|
|
810
|
+
email: 'upgrade@customer.com',
|
|
811
|
+
segment: 'smb',
|
|
812
|
+
tier: 'basic',
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
const upgraded = upgradeCustomer(customer, {
|
|
816
|
+
newTier: 'premium',
|
|
817
|
+
newMrr: 5000,
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
expect(upgraded.tier).toBe('premium')
|
|
821
|
+
expect(upgraded.mrr).toBe(5000)
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
it('should downgrade customer tier', () => {
|
|
825
|
+
const customer = createCustomer({
|
|
826
|
+
name: 'Downgrade Customer',
|
|
827
|
+
email: 'downgrade@customer.com',
|
|
828
|
+
segment: 'enterprise',
|
|
829
|
+
tier: 'premium',
|
|
830
|
+
mrr: 10000,
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
const downgraded = downgradeCustomer(customer, {
|
|
834
|
+
newTier: 'basic',
|
|
835
|
+
newMrr: 2000,
|
|
836
|
+
reason: 'budget-constraints',
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
expect(downgraded.tier).toBe('basic')
|
|
840
|
+
expect(downgraded.mrr).toBe(2000)
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
it('should mark customer as churned', () => {
|
|
844
|
+
const customer = createCustomer({
|
|
845
|
+
name: 'Churn Customer',
|
|
846
|
+
email: 'churn@customer.com',
|
|
847
|
+
segment: 'smb',
|
|
848
|
+
status: 'active',
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
const churned = churnCustomer(customer, {
|
|
852
|
+
reason: 'switched-to-competitor',
|
|
853
|
+
churnDate: new Date(),
|
|
854
|
+
feedback: 'Found better pricing elsewhere',
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
expect(churned.status).toBe('churned')
|
|
858
|
+
expect(churned.churnDate).toBeInstanceOf(Date)
|
|
859
|
+
expect(churned.churnReason).toBe('switched-to-competitor')
|
|
860
|
+
})
|
|
861
|
+
})
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
describe('Goals Type (v2)', () => {
|
|
865
|
+
describe('Goal interface', () => {
|
|
866
|
+
it('should have required properties', () => {
|
|
867
|
+
const goal: NewGoalDefinition = {
|
|
868
|
+
id: 'goal-001',
|
|
869
|
+
name: 'Increase Revenue',
|
|
870
|
+
status: 'active',
|
|
871
|
+
priority: 'high',
|
|
872
|
+
progress: 0,
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
expect(goal.id).toBe('goal-001')
|
|
876
|
+
expect(goal.name).toBe('Increase Revenue')
|
|
877
|
+
expect(goal.status).toBe('active')
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
it('should support hierarchy properties', () => {
|
|
881
|
+
const goal: NewGoalDefinition = {
|
|
882
|
+
id: 'goal-002',
|
|
883
|
+
name: 'Q1 Sales Target',
|
|
884
|
+
status: 'in-progress',
|
|
885
|
+
priority: 'high',
|
|
886
|
+
progress: 25,
|
|
887
|
+
parentId: 'goal-001',
|
|
888
|
+
level: 'team',
|
|
889
|
+
ownerId: 'emp-001',
|
|
890
|
+
teamId: 'team-001',
|
|
891
|
+
departmentId: 'dept-001',
|
|
892
|
+
alignedTo: ['goal-001'],
|
|
893
|
+
children: ['goal-003', 'goal-004'],
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
expect(goal.parentId).toBe('goal-001')
|
|
897
|
+
expect(goal.level).toBe('team')
|
|
898
|
+
expect(goal.alignedTo).toContain('goal-001')
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
it('should support measurement properties', () => {
|
|
902
|
+
const goal: NewGoalDefinition = {
|
|
903
|
+
id: 'goal-003',
|
|
904
|
+
name: 'Close 50 Deals',
|
|
905
|
+
status: 'in-progress',
|
|
906
|
+
priority: 'high',
|
|
907
|
+
progress: 40,
|
|
908
|
+
targetValue: 50,
|
|
909
|
+
currentValue: 20,
|
|
910
|
+
unit: 'deals',
|
|
911
|
+
startDate: new Date('2024-01-01'),
|
|
912
|
+
targetDate: new Date('2024-03-31'),
|
|
913
|
+
metrics: ['deals-closed', 'revenue-generated'],
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
expect(goal.targetValue).toBe(50)
|
|
917
|
+
expect(goal.currentValue).toBe(20)
|
|
918
|
+
expect(goal.progress).toBe(40)
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
it('should have valid status values', () => {
|
|
922
|
+
const statuses: GoalStatus[] = [
|
|
923
|
+
'draft',
|
|
924
|
+
'active',
|
|
925
|
+
'in-progress',
|
|
926
|
+
'at-risk',
|
|
927
|
+
'behind',
|
|
928
|
+
'completed',
|
|
929
|
+
'cancelled',
|
|
930
|
+
'deferred',
|
|
931
|
+
]
|
|
932
|
+
expect(statuses).toContain('active')
|
|
933
|
+
expect(statuses).toContain('at-risk')
|
|
934
|
+
expect(statuses).toContain('deferred')
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
it('should have valid priority values', () => {
|
|
938
|
+
const priorities: GoalPriority[] = ['critical', 'high', 'medium', 'low']
|
|
939
|
+
expect(priorities).toContain('critical')
|
|
940
|
+
expect(priorities).toContain('low')
|
|
941
|
+
})
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
describe('Goals factory functions', () => {
|
|
945
|
+
it('should create a goal hierarchy', () => {
|
|
946
|
+
const hierarchy = createGoalHierarchy({
|
|
947
|
+
company: {
|
|
948
|
+
name: 'Increase ARR by 50%',
|
|
949
|
+
priority: 'critical',
|
|
950
|
+
},
|
|
951
|
+
departments: [
|
|
952
|
+
{
|
|
953
|
+
name: 'Sales Revenue Target',
|
|
954
|
+
priority: 'high',
|
|
955
|
+
departmentId: 'sales',
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
name: 'Reduce Churn Rate',
|
|
959
|
+
priority: 'high',
|
|
960
|
+
departmentId: 'customer-success',
|
|
961
|
+
},
|
|
962
|
+
],
|
|
963
|
+
teams: [
|
|
964
|
+
{
|
|
965
|
+
name: 'Enterprise Sales',
|
|
966
|
+
priority: 'high',
|
|
967
|
+
teamId: 'enterprise',
|
|
968
|
+
parentDepartment: 'sales',
|
|
969
|
+
},
|
|
970
|
+
],
|
|
971
|
+
})
|
|
972
|
+
|
|
973
|
+
expect(hierarchy.company).toBeDefined()
|
|
974
|
+
expect(hierarchy.departments).toHaveLength(2)
|
|
975
|
+
expect(hierarchy.teams).toHaveLength(1)
|
|
976
|
+
expect(hierarchy.departments[0]?.alignedTo).toContain(hierarchy.company.id)
|
|
977
|
+
})
|
|
978
|
+
|
|
979
|
+
it('should align goals', () => {
|
|
980
|
+
const companyGoal = Goal({
|
|
981
|
+
name: 'Company Goal',
|
|
982
|
+
priority: 'critical',
|
|
983
|
+
level: 'company',
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
const teamGoal = Goal({
|
|
987
|
+
name: 'Team Goal',
|
|
988
|
+
priority: 'high',
|
|
989
|
+
level: 'team',
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
const aligned = alignGoals(teamGoal, companyGoal)
|
|
993
|
+
|
|
994
|
+
expect(aligned.alignedTo).toContain(companyGoal.id)
|
|
995
|
+
expect(aligned.parentId).toBe(companyGoal.id)
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
it('should cascade goals down the organization', () => {
|
|
999
|
+
const companyGoal = Goal({
|
|
1000
|
+
name: 'Grow 100%',
|
|
1001
|
+
priority: 'critical',
|
|
1002
|
+
level: 'company',
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
const cascaded = cascadeGoals(companyGoal, {
|
|
1006
|
+
departments: ['sales', 'marketing', 'product'],
|
|
1007
|
+
splitStrategy: 'equal',
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
expect(cascaded).toHaveLength(3)
|
|
1011
|
+
expect(cascaded[0]?.alignedTo).toContain(companyGoal.id)
|
|
1012
|
+
})
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
describe('Goals helper functions', () => {
|
|
1016
|
+
it('should get goals by owner', () => {
|
|
1017
|
+
const goals = Goals([
|
|
1018
|
+
{ name: 'Goal 1', priority: 'high', ownerId: 'emp-001' },
|
|
1019
|
+
{ name: 'Goal 2', priority: 'medium', ownerId: 'emp-002' },
|
|
1020
|
+
{ name: 'Goal 3', priority: 'low', ownerId: 'emp-001' },
|
|
1021
|
+
])
|
|
1022
|
+
|
|
1023
|
+
const ownerGoals = getGoalsByOwner(goals, 'emp-001')
|
|
1024
|
+
expect(ownerGoals).toHaveLength(2)
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
it('should get goals at risk', () => {
|
|
1028
|
+
const goals = Goals([
|
|
1029
|
+
{ name: 'On Track', priority: 'high', status: 'in-progress', progress: 80 },
|
|
1030
|
+
{ name: 'At Risk', priority: 'high', status: 'at-risk', progress: 30 },
|
|
1031
|
+
{ name: 'Behind', priority: 'medium', status: 'behind', progress: 10 },
|
|
1032
|
+
])
|
|
1033
|
+
|
|
1034
|
+
const atRiskGoals = getGoalsAtRisk(goals)
|
|
1035
|
+
expect(atRiskGoals).toHaveLength(2)
|
|
1036
|
+
expect(atRiskGoals.map((g) => g.name)).toContain('At Risk')
|
|
1037
|
+
expect(atRiskGoals.map((g) => g.name)).toContain('Behind')
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
it('should calculate goal progress from current/target values', () => {
|
|
1041
|
+
const goal = Goal({
|
|
1042
|
+
name: 'Close Deals',
|
|
1043
|
+
priority: 'high',
|
|
1044
|
+
targetValue: 100,
|
|
1045
|
+
currentValue: 35,
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
const progress = calculateGoalProgress(goal)
|
|
1049
|
+
expect(progress).toBe(35)
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
it('should rollup progress from child goals', () => {
|
|
1053
|
+
const parentGoal = Goal({
|
|
1054
|
+
name: 'Parent Goal',
|
|
1055
|
+
priority: 'high',
|
|
1056
|
+
children: ['child-1', 'child-2', 'child-3'],
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
const childGoals = Goals([
|
|
1060
|
+
{ id: 'child-1', name: 'Child 1', priority: 'high', progress: 100 },
|
|
1061
|
+
{ id: 'child-2', name: 'Child 2', priority: 'high', progress: 50 },
|
|
1062
|
+
{ id: 'child-3', name: 'Child 3', priority: 'medium', progress: 25 },
|
|
1063
|
+
])
|
|
1064
|
+
|
|
1065
|
+
const rolledUpProgress = rollupProgress(parentGoal, childGoals)
|
|
1066
|
+
expect(rolledUpProgress).toBeCloseTo(58.33, 1) // Average of 100, 50, 25
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
it('should calculate weighted rollup based on priority', () => {
|
|
1070
|
+
const parentGoal = Goal({
|
|
1071
|
+
name: 'Parent Goal',
|
|
1072
|
+
priority: 'high',
|
|
1073
|
+
children: ['child-1', 'child-2'],
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1076
|
+
const childGoals = Goals([
|
|
1077
|
+
{ id: 'child-1', name: 'Critical Child', priority: 'critical', progress: 100, weight: 0.7 },
|
|
1078
|
+
{ id: 'child-2', name: 'Low Child', priority: 'low', progress: 0, weight: 0.3 },
|
|
1079
|
+
])
|
|
1080
|
+
|
|
1081
|
+
const weightedProgress = rollupProgress(parentGoal, childGoals, { weighted: true })
|
|
1082
|
+
expect(weightedProgress).toBe(70) // 100 * 0.7 + 0 * 0.3 = 70
|
|
1083
|
+
})
|
|
1084
|
+
})
|
|
1085
|
+
|
|
1086
|
+
describe('Goal relationships', () => {
|
|
1087
|
+
it('should support parent-child relationships', () => {
|
|
1088
|
+
const parent = Goal({
|
|
1089
|
+
name: 'Parent',
|
|
1090
|
+
priority: 'high',
|
|
1091
|
+
})
|
|
1092
|
+
|
|
1093
|
+
const child1 = Goal({
|
|
1094
|
+
name: 'Child 1',
|
|
1095
|
+
priority: 'medium',
|
|
1096
|
+
parentId: parent.id,
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
const child2 = Goal({
|
|
1100
|
+
name: 'Child 2',
|
|
1101
|
+
priority: 'medium',
|
|
1102
|
+
parentId: parent.id,
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
expect(child1.parentId).toBe(parent.id)
|
|
1106
|
+
expect(child2.parentId).toBe(parent.id)
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
it('should support alignment relationships', () => {
|
|
1110
|
+
const strategic = Goal({
|
|
1111
|
+
name: 'Strategic Goal',
|
|
1112
|
+
priority: 'critical',
|
|
1113
|
+
level: 'company',
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
const operational = Goal({
|
|
1117
|
+
name: 'Operational Goal',
|
|
1118
|
+
priority: 'high',
|
|
1119
|
+
level: 'department',
|
|
1120
|
+
alignedTo: [strategic.id],
|
|
1121
|
+
})
|
|
1122
|
+
|
|
1123
|
+
expect(operational.alignedTo).toContain(strategic.id)
|
|
1124
|
+
})
|
|
1125
|
+
|
|
1126
|
+
it('should support dependency relationships', () => {
|
|
1127
|
+
const prerequisite = Goal({
|
|
1128
|
+
name: 'Prerequisite Goal',
|
|
1129
|
+
priority: 'high',
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
const dependent = Goal({
|
|
1133
|
+
name: 'Dependent Goal',
|
|
1134
|
+
priority: 'medium',
|
|
1135
|
+
dependencies: [prerequisite.id],
|
|
1136
|
+
})
|
|
1137
|
+
|
|
1138
|
+
expect(dependent.dependencies).toContain(prerequisite.id)
|
|
1139
|
+
})
|
|
1140
|
+
})
|
|
1141
|
+
})
|