spine-framework-portal 0.2.16 → 0.2.18

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.
@@ -0,0 +1,256 @@
1
+ // Funnel Scoring Engine
2
+ // Pure calculation utilities - NO database access
3
+ // All functions are deterministic and testable
4
+
5
+ // ============================================
6
+ // TYPES
7
+ // ============================================
8
+
9
+ export interface EngagementResult {
10
+ type: 1 | 2 | 5
11
+ context: 'first_visit' | 'deep_session' | 'return_visit'
12
+ session_depth: number
13
+ prior_session_count?: number
14
+ }
15
+
16
+ export interface RecencyResult {
17
+ divisor: 1 | 2 | 5 | null
18
+ age_days: number
19
+ window: 'fresh' | 'cooling' | 'stale' | 'expired'
20
+ }
21
+
22
+ export interface RawScoreResult {
23
+ calculated: number
24
+ max_possible: number
25
+ rating: 1 | 2 | 3 | 4 | 5
26
+ }
27
+
28
+ export interface StageConfig {
29
+ max_lookback_days: number
30
+ fresh_days: number
31
+ cooling_days: number
32
+ stale_days: number
33
+ deep_engagement_action_count: number
34
+ }
35
+
36
+ // Stage configurations per plan
37
+ const STAGE_CONFIGS: Record<string, StageConfig> = {
38
+ anonymous: {
39
+ max_lookback_days: 90,
40
+ fresh_days: 7,
41
+ cooling_days: 30,
42
+ stale_days: 90,
43
+ deep_engagement_action_count: 4
44
+ },
45
+ identified: {
46
+ max_lookback_days: 120,
47
+ fresh_days: 14,
48
+ cooling_days: 45,
49
+ stale_days: 90,
50
+ deep_engagement_action_count: 3
51
+ },
52
+ installed: {
53
+ max_lookback_days: 90,
54
+ fresh_days: 7,
55
+ cooling_days: 21,
56
+ stale_days: 45,
57
+ deep_engagement_action_count: 3
58
+ }
59
+ }
60
+
61
+ // ============================================
62
+ // ENGAGEMENT CALCULATION
63
+ // ============================================
64
+
65
+ export function calculateEngagement(
66
+ priorSignals: Array<{ session_id: string; occurred_at: string }>,
67
+ currentSessionId: string,
68
+ currentOccurredAt: string,
69
+ stage: string
70
+ ): EngagementResult {
71
+ const config = STAGE_CONFIGS[stage] || STAGE_CONFIGS.anonymous
72
+
73
+ // First visit - no prior signals
74
+ if (priorSignals.length === 0) {
75
+ return { type: 1, context: 'first_visit', session_depth: 1 }
76
+ }
77
+
78
+ // Check for return visit
79
+ const lastSignal = priorSignals[priorSignals.length - 1]
80
+ const hoursSinceLast = differenceInHours(
81
+ new Date(currentOccurredAt),
82
+ new Date(lastSignal.occurred_at)
83
+ )
84
+ const isNewSession = currentSessionId !== lastSignal.session_id
85
+
86
+ if (isNewSession || hoursSinceLast >= 4) {
87
+ const uniqueSessions = new Set(priorSignals.map(s => s.session_id)).size
88
+ return {
89
+ type: 5,
90
+ context: 'return_visit',
91
+ session_depth: 1,
92
+ prior_session_count: uniqueSessions
93
+ }
94
+ }
95
+
96
+ // Same session - check depth
97
+ const sameSessionSignals = priorSignals.filter(s =>
98
+ s.session_id === currentSessionId &&
99
+ isSameDay(new Date(s.occurred_at), new Date(currentOccurredAt))
100
+ )
101
+
102
+ const sessionDepth = sameSessionSignals.length + 1
103
+
104
+ if (sessionDepth >= config.deep_engagement_action_count) {
105
+ return { type: 2, context: 'deep_session', session_depth: sessionDepth }
106
+ }
107
+
108
+ return { type: 1, context: 'first_visit', session_depth: sessionDepth }
109
+ }
110
+
111
+ // ============================================
112
+ // RECENCY CALCULATION
113
+ // ============================================
114
+
115
+ export function calculateRecency(
116
+ occurredAt: Date,
117
+ now: Date = new Date(),
118
+ stage: string = 'anonymous'
119
+ ): RecencyResult {
120
+ const config = STAGE_CONFIGS[stage] || STAGE_CONFIGS.anonymous
121
+ const ageDays = differenceInDays(now, occurredAt)
122
+
123
+ if (ageDays > config.max_lookback_days) {
124
+ return { divisor: null, age_days: ageDays, window: 'expired' }
125
+ }
126
+
127
+ if (ageDays <= config.fresh_days) {
128
+ return { divisor: 1, age_days: ageDays, window: 'fresh' }
129
+ }
130
+
131
+ if (ageDays <= config.cooling_days) {
132
+ return { divisor: 2, age_days: ageDays, window: 'cooling' }
133
+ }
134
+
135
+ return { divisor: 5, age_days: ageDays, window: 'stale' }
136
+ }
137
+
138
+ // ============================================
139
+ // RAW SCORE CALCULATION
140
+ // ============================================
141
+
142
+ export function calculateRawScore(
143
+ actionValue: 1 | 2 | 5,
144
+ engagementType: 1 | 2 | 5,
145
+ recencyDivisor: 1 | 2 | 5
146
+ ): RawScoreResult {
147
+ const calculated = (actionValue * engagementType) / recencyDivisor
148
+
149
+ let rating: 1 | 2 | 3 | 4 | 5
150
+ if (calculated <= 1) rating = 1
151
+ else if (calculated <= 4) rating = 2
152
+ else if (calculated <= 8) rating = 3
153
+ else if (calculated <= 15) rating = 4
154
+ else rating = 5
155
+
156
+ return {
157
+ calculated,
158
+ max_possible: 25, // 5 * 5 / 1
159
+ rating
160
+ }
161
+ }
162
+
163
+ // ============================================
164
+ // BEST-SIGNAL-WINS CALCULATION
165
+ // ============================================
166
+
167
+ export function findBestSignal<T extends { scoring_components?: { raw_score?: { calculated?: number; rating?: number } } }>(
168
+ signals: T[]
169
+ ): { signal: T | null; rating: number; raw_score: number } {
170
+ if (signals.length === 0) {
171
+ return { signal: null, rating: 0, raw_score: 0 }
172
+ }
173
+
174
+ let bestSignal = signals[0]
175
+ let bestScore = signals[0]?.scoring_components?.raw_score?.calculated || 0
176
+
177
+ for (const signal of signals) {
178
+ const score = signal?.scoring_components?.raw_score?.calculated || 0
179
+ if (score > bestScore) {
180
+ bestScore = score
181
+ bestSignal = signal
182
+ }
183
+ }
184
+
185
+ return {
186
+ signal: bestSignal,
187
+ rating: bestSignal?.scoring_components?.raw_score?.rating || 0,
188
+ raw_score: bestScore
189
+ }
190
+ }
191
+
192
+ // ============================================
193
+ // UTILITY FUNCTIONS
194
+ // ============================================
195
+
196
+ function differenceInHours(date1: Date, date2: Date): number {
197
+ const msPerHour = 1000 * 60 * 60
198
+ return Math.abs(date1.getTime() - date2.getTime()) / msPerHour
199
+ }
200
+
201
+ function differenceInDays(date1: Date, date2: Date): number {
202
+ return Math.floor(differenceInHours(date1, date2) / 24)
203
+ }
204
+
205
+ function isSameDay(date1: Date, date2: Date): boolean {
206
+ return date1.toDateString() === date2.toDateString()
207
+ }
208
+
209
+ // ============================================
210
+ // REFERRER CATEGORIZATION
211
+ // ============================================
212
+
213
+ export function categorizeReferrer(referrerDomain: string): string {
214
+ const social = ['linkedin.com', 'twitter.com', 'x.com', 'facebook.com', 'instagram.com']
215
+ const search = ['google.com', 'bing.com', 'duckduckgo.com']
216
+
217
+ const domain = referrerDomain.toLowerCase()
218
+
219
+ if (social.some(s => domain.includes(s))) return 'social'
220
+ if (search.some(s => domain.includes(s))) return 'search'
221
+ if (!domain || domain === 'direct') return 'direct'
222
+
223
+ return 'referral'
224
+ }
225
+
226
+ // ============================================
227
+ // OPPORTUNITY TYPE INFERENCE
228
+ // ============================================
229
+
230
+ export function inferOpportunityType(
231
+ signals: Array<{ action?: { action_type: string } }>,
232
+ stage: string,
233
+ rating: number
234
+ ): { type: string; confidence: 'low' | 'medium' | 'high' } {
235
+ const actionTypes = signals.map(s => s.action?.action_type || '').join(' ')
236
+
237
+ // High-value signals indicate specific opportunities
238
+ if (actionTypes.includes('pricing') && rating >= 4) {
239
+ return { type: 'advanced_portal', confidence: 'high' }
240
+ }
241
+ if (actionTypes.includes('health_ping') && actionTypes.includes('production')) {
242
+ return { type: 'managed_services', confidence: 'high' }
243
+ }
244
+ if (actionTypes.includes('support_ticket') && rating >= 3) {
245
+ return { type: 'support_plan', confidence: 'medium' }
246
+ }
247
+ if (stage === 'installed' && rating >= 4) {
248
+ return { type: 'expansion', confidence: 'medium' }
249
+ }
250
+
251
+ // Default based on stage
252
+ if (stage === 'anonymous') return { type: 'implementation', confidence: 'low' }
253
+ if (stage === 'identified') return { type: 'advanced_portal', confidence: 'low' }
254
+
255
+ return { type: 'advocate', confidence: 'low' }
256
+ }