@loamly/tracker 1.8.0 → 2.0.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/README.md +146 -47
- package/dist/index.cjs +1244 -357
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +78 -65
- package/dist/index.d.ts +78 -65
- package/dist/index.mjs +1244 -357
- package/dist/index.mjs.map +1 -1
- package/dist/loamly.iife.global.js +1291 -140
- package/dist/loamly.iife.global.js.map +1 -1
- package/dist/loamly.iife.min.global.js +16 -1
- package/dist/loamly.iife.min.global.js.map +1 -1
- package/package.json +2 -2
- package/src/behavioral/form-tracker.ts +325 -0
- package/src/behavioral/index.ts +9 -0
- package/src/behavioral/scroll-tracker.ts +163 -0
- package/src/behavioral/time-tracker.ts +174 -0
- package/src/browser.ts +127 -36
- package/src/config.ts +1 -1
- package/src/core.ts +278 -156
- package/src/infrastructure/event-queue.ts +225 -0
- package/src/infrastructure/index.ts +8 -0
- package/src/infrastructure/ping.ts +149 -0
- package/src/spa/index.ts +7 -0
- package/src/spa/router.ts +147 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal Form Tracker
|
|
3
|
+
*
|
|
4
|
+
* Comprehensive form tracking with support for:
|
|
5
|
+
* - Native HTML forms
|
|
6
|
+
* - HubSpot forms
|
|
7
|
+
* - Typeform embeds
|
|
8
|
+
* - JotForm embeds
|
|
9
|
+
* - Gravity Forms
|
|
10
|
+
* - Thank-you page detection
|
|
11
|
+
* - Privacy-preserving field capture
|
|
12
|
+
*
|
|
13
|
+
* @module @loamly/tracker
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface FormEvent {
|
|
17
|
+
event_type: 'form_start' | 'form_field' | 'form_submit' | 'form_success'
|
|
18
|
+
form_id: string
|
|
19
|
+
form_type: 'native' | 'hubspot' | 'typeform' | 'jotform' | 'gravity' | 'unknown'
|
|
20
|
+
field_name?: string
|
|
21
|
+
field_type?: string
|
|
22
|
+
time_to_submit_ms?: number
|
|
23
|
+
is_conversion?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FormTrackerConfig {
|
|
27
|
+
// Privacy: Never capture these field values
|
|
28
|
+
sensitiveFields: string[]
|
|
29
|
+
// Fields to track interaction (not values)
|
|
30
|
+
trackableFields: string[]
|
|
31
|
+
// Patterns for thank-you page detection
|
|
32
|
+
thankYouPatterns: RegExp[]
|
|
33
|
+
// Callback for form events
|
|
34
|
+
onFormEvent?: (event: FormEvent) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_CONFIG: FormTrackerConfig = {
|
|
38
|
+
sensitiveFields: [
|
|
39
|
+
'password', 'pwd', 'pass',
|
|
40
|
+
'credit', 'card', 'cvv', 'cvc',
|
|
41
|
+
'ssn', 'social',
|
|
42
|
+
'secret', 'token', 'key',
|
|
43
|
+
],
|
|
44
|
+
trackableFields: [
|
|
45
|
+
'email', 'name', 'phone', 'company',
|
|
46
|
+
'first', 'last', 'city', 'country',
|
|
47
|
+
],
|
|
48
|
+
thankYouPatterns: [
|
|
49
|
+
/thank[-_]?you/i,
|
|
50
|
+
/success/i,
|
|
51
|
+
/confirmation/i,
|
|
52
|
+
/submitted/i,
|
|
53
|
+
/complete/i,
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class FormTracker {
|
|
58
|
+
private config: FormTrackerConfig
|
|
59
|
+
private formStartTimes = new Map<string, number>()
|
|
60
|
+
private interactedForms = new Set<string>()
|
|
61
|
+
private mutationObserver: MutationObserver | null = null
|
|
62
|
+
|
|
63
|
+
constructor(config: Partial<FormTrackerConfig> = {}) {
|
|
64
|
+
this.config = {
|
|
65
|
+
...DEFAULT_CONFIG,
|
|
66
|
+
...config,
|
|
67
|
+
sensitiveFields: [
|
|
68
|
+
...DEFAULT_CONFIG.sensitiveFields,
|
|
69
|
+
...(config.sensitiveFields || []),
|
|
70
|
+
],
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Start tracking forms
|
|
76
|
+
*/
|
|
77
|
+
start(): void {
|
|
78
|
+
// Track native form interactions
|
|
79
|
+
document.addEventListener('focusin', this.handleFocusIn, { passive: true })
|
|
80
|
+
document.addEventListener('submit', this.handleSubmit)
|
|
81
|
+
document.addEventListener('click', this.handleClick, { passive: true })
|
|
82
|
+
|
|
83
|
+
// Observe DOM for dynamically added forms (HubSpot, Typeform, etc.)
|
|
84
|
+
this.startMutationObserver()
|
|
85
|
+
|
|
86
|
+
// Check for thank-you page on load
|
|
87
|
+
this.checkThankYouPage()
|
|
88
|
+
|
|
89
|
+
// Scan for existing embedded forms
|
|
90
|
+
this.scanForEmbeddedForms()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Stop tracking
|
|
95
|
+
*/
|
|
96
|
+
stop(): void {
|
|
97
|
+
document.removeEventListener('focusin', this.handleFocusIn)
|
|
98
|
+
document.removeEventListener('submit', this.handleSubmit)
|
|
99
|
+
document.removeEventListener('click', this.handleClick)
|
|
100
|
+
this.mutationObserver?.disconnect()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get forms that had interaction
|
|
105
|
+
*/
|
|
106
|
+
getInteractedForms(): string[] {
|
|
107
|
+
return Array.from(this.interactedForms)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private handleFocusIn = (e: FocusEvent): void => {
|
|
111
|
+
const target = e.target as HTMLElement
|
|
112
|
+
if (!this.isFormField(target)) return
|
|
113
|
+
|
|
114
|
+
const form = target.closest('form')
|
|
115
|
+
const formId = this.getFormId(form || target)
|
|
116
|
+
|
|
117
|
+
// Track form start
|
|
118
|
+
if (!this.formStartTimes.has(formId)) {
|
|
119
|
+
this.formStartTimes.set(formId, Date.now())
|
|
120
|
+
this.interactedForms.add(formId)
|
|
121
|
+
|
|
122
|
+
this.emitEvent({
|
|
123
|
+
event_type: 'form_start',
|
|
124
|
+
form_id: formId,
|
|
125
|
+
form_type: this.detectFormType(form || target),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Track field interaction (privacy-safe)
|
|
130
|
+
const fieldName = this.getFieldName(target as HTMLInputElement)
|
|
131
|
+
if (fieldName && !this.isSensitiveField(fieldName)) {
|
|
132
|
+
this.emitEvent({
|
|
133
|
+
event_type: 'form_field',
|
|
134
|
+
form_id: formId,
|
|
135
|
+
form_type: this.detectFormType(form || target),
|
|
136
|
+
field_name: this.sanitizeFieldName(fieldName),
|
|
137
|
+
field_type: (target as HTMLInputElement).type || target.tagName.toLowerCase(),
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private handleSubmit = (e: Event): void => {
|
|
143
|
+
const form = e.target as HTMLFormElement
|
|
144
|
+
if (!form || form.tagName !== 'FORM') return
|
|
145
|
+
|
|
146
|
+
const formId = this.getFormId(form)
|
|
147
|
+
const startTime = this.formStartTimes.get(formId)
|
|
148
|
+
|
|
149
|
+
this.emitEvent({
|
|
150
|
+
event_type: 'form_submit',
|
|
151
|
+
form_id: formId,
|
|
152
|
+
form_type: this.detectFormType(form),
|
|
153
|
+
time_to_submit_ms: startTime ? Date.now() - startTime : undefined,
|
|
154
|
+
is_conversion: true,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private handleClick = (e: Event): void => {
|
|
159
|
+
const target = e.target as HTMLElement
|
|
160
|
+
|
|
161
|
+
// Check for HubSpot submit button
|
|
162
|
+
if (target.closest('.hs-button') || target.closest('[type="submit"]')) {
|
|
163
|
+
const form = target.closest('form')
|
|
164
|
+
if (form && form.classList.contains('hs-form')) {
|
|
165
|
+
const formId = this.getFormId(form)
|
|
166
|
+
const startTime = this.formStartTimes.get(formId)
|
|
167
|
+
|
|
168
|
+
this.emitEvent({
|
|
169
|
+
event_type: 'form_submit',
|
|
170
|
+
form_id: formId,
|
|
171
|
+
form_type: 'hubspot',
|
|
172
|
+
time_to_submit_ms: startTime ? Date.now() - startTime : undefined,
|
|
173
|
+
is_conversion: true,
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Check for Typeform submit
|
|
179
|
+
if (target.closest('[data-qa="submit-button"]')) {
|
|
180
|
+
this.emitEvent({
|
|
181
|
+
event_type: 'form_submit',
|
|
182
|
+
form_id: 'typeform_embed',
|
|
183
|
+
form_type: 'typeform',
|
|
184
|
+
is_conversion: true,
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private startMutationObserver(): void {
|
|
190
|
+
this.mutationObserver = new MutationObserver((mutations) => {
|
|
191
|
+
for (const mutation of mutations) {
|
|
192
|
+
for (const node of mutation.addedNodes) {
|
|
193
|
+
if (node instanceof HTMLElement) {
|
|
194
|
+
// Check for HubSpot form
|
|
195
|
+
if (node.classList?.contains('hs-form') || node.querySelector?.('.hs-form')) {
|
|
196
|
+
this.trackEmbeddedForm(node, 'hubspot')
|
|
197
|
+
}
|
|
198
|
+
// Check for Typeform
|
|
199
|
+
if (node.classList?.contains('typeform-widget') || node.querySelector?.('[data-tf-widget]')) {
|
|
200
|
+
this.trackEmbeddedForm(node, 'typeform')
|
|
201
|
+
}
|
|
202
|
+
// Check for JotForm
|
|
203
|
+
if (node.classList?.contains('jotform-form') || node.querySelector?.('.jotform-form')) {
|
|
204
|
+
this.trackEmbeddedForm(node, 'jotform')
|
|
205
|
+
}
|
|
206
|
+
// Check for Gravity Forms
|
|
207
|
+
if (node.classList?.contains('gform_wrapper') || node.querySelector?.('.gform_wrapper')) {
|
|
208
|
+
this.trackEmbeddedForm(node, 'gravity')
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
this.mutationObserver.observe(document.body, {
|
|
216
|
+
childList: true,
|
|
217
|
+
subtree: true,
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private scanForEmbeddedForms(): void {
|
|
222
|
+
// HubSpot
|
|
223
|
+
document.querySelectorAll('.hs-form').forEach(form => {
|
|
224
|
+
this.trackEmbeddedForm(form as HTMLElement, 'hubspot')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Typeform
|
|
228
|
+
document.querySelectorAll('[data-tf-widget], .typeform-widget').forEach(form => {
|
|
229
|
+
this.trackEmbeddedForm(form as HTMLElement, 'typeform')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// JotForm
|
|
233
|
+
document.querySelectorAll('.jotform-form').forEach(form => {
|
|
234
|
+
this.trackEmbeddedForm(form as HTMLElement, 'jotform')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
// Gravity Forms
|
|
238
|
+
document.querySelectorAll('.gform_wrapper').forEach(form => {
|
|
239
|
+
this.trackEmbeddedForm(form as HTMLElement, 'gravity')
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private trackEmbeddedForm(element: HTMLElement, type: FormEvent['form_type']): void {
|
|
244
|
+
const formId = `${type}_${this.getFormId(element)}`
|
|
245
|
+
|
|
246
|
+
// Add event listeners for embedded form interactions
|
|
247
|
+
element.addEventListener('focusin', () => {
|
|
248
|
+
if (!this.formStartTimes.has(formId)) {
|
|
249
|
+
this.formStartTimes.set(formId, Date.now())
|
|
250
|
+
this.interactedForms.add(formId)
|
|
251
|
+
|
|
252
|
+
this.emitEvent({
|
|
253
|
+
event_type: 'form_start',
|
|
254
|
+
form_id: formId,
|
|
255
|
+
form_type: type,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
}, { passive: true })
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private checkThankYouPage(): void {
|
|
262
|
+
const url = window.location.href.toLowerCase()
|
|
263
|
+
const title = document.title.toLowerCase()
|
|
264
|
+
|
|
265
|
+
for (const pattern of this.config.thankYouPatterns) {
|
|
266
|
+
if (pattern.test(url) || pattern.test(title)) {
|
|
267
|
+
this.emitEvent({
|
|
268
|
+
event_type: 'form_success',
|
|
269
|
+
form_id: 'page_conversion',
|
|
270
|
+
form_type: 'unknown',
|
|
271
|
+
is_conversion: true,
|
|
272
|
+
})
|
|
273
|
+
break
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private isFormField(element: HTMLElement): boolean {
|
|
279
|
+
const tagName = element.tagName
|
|
280
|
+
return tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private getFormId(element: HTMLElement | null): string {
|
|
284
|
+
if (!element) return 'unknown'
|
|
285
|
+
return element.id || element.getAttribute('name') || element.getAttribute('data-form-id') || 'form_' + Math.random().toString(36).substring(2, 8)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private getFieldName(input: HTMLInputElement): string {
|
|
289
|
+
return input.name || input.id || input.getAttribute('data-name') || ''
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private isSensitiveField(fieldName: string): boolean {
|
|
293
|
+
const lowerName = fieldName.toLowerCase()
|
|
294
|
+
return this.config.sensitiveFields.some(sensitive => lowerName.includes(sensitive))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private sanitizeFieldName(fieldName: string): string {
|
|
298
|
+
// Remove any potential PII from field names
|
|
299
|
+
return fieldName.replace(/[0-9]+/g, '*').substring(0, 50)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private detectFormType(element: HTMLElement): FormEvent['form_type'] {
|
|
303
|
+
if (element.classList.contains('hs-form') || element.closest('.hs-form')) {
|
|
304
|
+
return 'hubspot'
|
|
305
|
+
}
|
|
306
|
+
if (element.classList.contains('typeform-widget') || element.closest('[data-tf-widget]')) {
|
|
307
|
+
return 'typeform'
|
|
308
|
+
}
|
|
309
|
+
if (element.classList.contains('jotform-form') || element.closest('.jotform-form')) {
|
|
310
|
+
return 'jotform'
|
|
311
|
+
}
|
|
312
|
+
if (element.classList.contains('gform_wrapper') || element.closest('.gform_wrapper')) {
|
|
313
|
+
return 'gravity'
|
|
314
|
+
}
|
|
315
|
+
if (element.tagName === 'FORM') {
|
|
316
|
+
return 'native'
|
|
317
|
+
}
|
|
318
|
+
return 'unknown'
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private emitEvent(event: FormEvent): void {
|
|
322
|
+
this.config.onFormEvent?.(event)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavioral Tracking Modules
|
|
3
|
+
* @module @loamly/tracker
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { ScrollTracker, type ScrollEvent, type ScrollTrackerConfig } from './scroll-tracker'
|
|
7
|
+
export { TimeTracker, type TimeEvent, type TimeTrackerConfig } from './time-tracker'
|
|
8
|
+
export { FormTracker, type FormEvent, type FormTrackerConfig } from './form-tracker'
|
|
9
|
+
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Advanced Scroll Depth Tracker
|
|
3
|
+
*
|
|
4
|
+
* Production-grade scroll tracking with:
|
|
5
|
+
* - 30% chunk reporting (30%, 60%, 90%, 100%)
|
|
6
|
+
* - requestAnimationFrame throttling for performance
|
|
7
|
+
* - Visibility-aware (only tracks when visible)
|
|
8
|
+
* - Max depth tracking
|
|
9
|
+
*
|
|
10
|
+
* @module @loamly/tracker
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ScrollEvent {
|
|
14
|
+
depth: number
|
|
15
|
+
chunk: number // 30, 60, 90, 100
|
|
16
|
+
time_to_reach_ms: number
|
|
17
|
+
total_height: number
|
|
18
|
+
viewport_height: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ScrollTrackerConfig {
|
|
22
|
+
chunks: number[] // Default: [30, 60, 90, 100]
|
|
23
|
+
onChunkReached?: (event: ScrollEvent) => void
|
|
24
|
+
onDepthChange?: (depth: number) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_CHUNKS = [30, 60, 90, 100]
|
|
28
|
+
|
|
29
|
+
export class ScrollTracker {
|
|
30
|
+
private config: ScrollTrackerConfig
|
|
31
|
+
private maxDepth = 0
|
|
32
|
+
private reportedChunks = new Set<number>()
|
|
33
|
+
private startTime: number
|
|
34
|
+
private ticking = false
|
|
35
|
+
private isVisible = true
|
|
36
|
+
|
|
37
|
+
constructor(config: Partial<ScrollTrackerConfig> = {}) {
|
|
38
|
+
this.config = {
|
|
39
|
+
chunks: DEFAULT_CHUNKS,
|
|
40
|
+
...config,
|
|
41
|
+
}
|
|
42
|
+
this.startTime = Date.now()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Start tracking scroll depth
|
|
47
|
+
*/
|
|
48
|
+
start(): void {
|
|
49
|
+
window.addEventListener('scroll', this.handleScroll, { passive: true })
|
|
50
|
+
document.addEventListener('visibilitychange', this.handleVisibility)
|
|
51
|
+
|
|
52
|
+
// Check initial scroll position (for page refresh)
|
|
53
|
+
this.checkScrollDepth()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stop tracking
|
|
58
|
+
*/
|
|
59
|
+
stop(): void {
|
|
60
|
+
window.removeEventListener('scroll', this.handleScroll)
|
|
61
|
+
document.removeEventListener('visibilitychange', this.handleVisibility)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get current max scroll depth
|
|
66
|
+
*/
|
|
67
|
+
getMaxDepth(): number {
|
|
68
|
+
return this.maxDepth
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get reported chunks
|
|
73
|
+
*/
|
|
74
|
+
getReportedChunks(): number[] {
|
|
75
|
+
return Array.from(this.reportedChunks).sort((a, b) => a - b)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get final scroll event (for unload)
|
|
80
|
+
*/
|
|
81
|
+
getFinalEvent(): ScrollEvent {
|
|
82
|
+
const docHeight = document.documentElement.scrollHeight
|
|
83
|
+
const viewportHeight = window.innerHeight
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
depth: this.maxDepth,
|
|
87
|
+
chunk: this.getChunkForDepth(this.maxDepth),
|
|
88
|
+
time_to_reach_ms: Date.now() - this.startTime,
|
|
89
|
+
total_height: docHeight,
|
|
90
|
+
viewport_height: viewportHeight,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private handleScroll = (): void => {
|
|
95
|
+
if (!this.ticking && this.isVisible) {
|
|
96
|
+
requestAnimationFrame(() => {
|
|
97
|
+
this.checkScrollDepth()
|
|
98
|
+
this.ticking = false
|
|
99
|
+
})
|
|
100
|
+
this.ticking = true
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private handleVisibility = (): void => {
|
|
105
|
+
this.isVisible = document.visibilityState === 'visible'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private checkScrollDepth(): void {
|
|
109
|
+
const scrollY = window.scrollY
|
|
110
|
+
const viewportHeight = window.innerHeight
|
|
111
|
+
const docHeight = document.documentElement.scrollHeight
|
|
112
|
+
|
|
113
|
+
// Avoid division by zero
|
|
114
|
+
if (docHeight <= viewportHeight) {
|
|
115
|
+
this.updateDepth(100)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const scrollableHeight = docHeight - viewportHeight
|
|
120
|
+
const currentDepth = Math.min(100, Math.round((scrollY / scrollableHeight) * 100))
|
|
121
|
+
|
|
122
|
+
this.updateDepth(currentDepth)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private updateDepth(depth: number): void {
|
|
126
|
+
if (depth <= this.maxDepth) return
|
|
127
|
+
|
|
128
|
+
this.maxDepth = depth
|
|
129
|
+
this.config.onDepthChange?.(depth)
|
|
130
|
+
|
|
131
|
+
// Check for chunk milestones
|
|
132
|
+
for (const chunk of this.config.chunks!) {
|
|
133
|
+
if (depth >= chunk && !this.reportedChunks.has(chunk)) {
|
|
134
|
+
this.reportedChunks.add(chunk)
|
|
135
|
+
this.reportChunk(chunk)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private reportChunk(chunk: number): void {
|
|
141
|
+
const docHeight = document.documentElement.scrollHeight
|
|
142
|
+
const viewportHeight = window.innerHeight
|
|
143
|
+
|
|
144
|
+
const event: ScrollEvent = {
|
|
145
|
+
depth: this.maxDepth,
|
|
146
|
+
chunk,
|
|
147
|
+
time_to_reach_ms: Date.now() - this.startTime,
|
|
148
|
+
total_height: docHeight,
|
|
149
|
+
viewport_height: viewportHeight,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.config.onChunkReached?.(event)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private getChunkForDepth(depth: number): number {
|
|
156
|
+
const chunks = this.config.chunks!.sort((a, b) => b - a)
|
|
157
|
+
for (const chunk of chunks) {
|
|
158
|
+
if (depth >= chunk) return chunk
|
|
159
|
+
}
|
|
160
|
+
return 0
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time Spent Tracker
|
|
3
|
+
*
|
|
4
|
+
* Accurate time-on-page tracking with:
|
|
5
|
+
* - Visibility-aware (pauses when tab hidden)
|
|
6
|
+
* - Heartbeat updates
|
|
7
|
+
* - Engagement detection (active vs idle)
|
|
8
|
+
*
|
|
9
|
+
* @module @loamly/tracker
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface TimeEvent {
|
|
13
|
+
active_time_ms: number
|
|
14
|
+
total_time_ms: number
|
|
15
|
+
idle_time_ms: number
|
|
16
|
+
is_engaged: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TimeTrackerConfig {
|
|
20
|
+
idleThresholdMs: number // Time without interaction to consider idle
|
|
21
|
+
updateIntervalMs: number // How often to report time
|
|
22
|
+
onUpdate?: (event: TimeEvent) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_CONFIG: TimeTrackerConfig = {
|
|
26
|
+
idleThresholdMs: 30000, // 30 seconds
|
|
27
|
+
updateIntervalMs: 5000, // 5 seconds
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class TimeTracker {
|
|
31
|
+
private config: TimeTrackerConfig
|
|
32
|
+
private startTime: number
|
|
33
|
+
private activeTime = 0
|
|
34
|
+
private idleTime = 0
|
|
35
|
+
private lastActivityTime: number
|
|
36
|
+
private lastUpdateTime: number
|
|
37
|
+
private isVisible = true
|
|
38
|
+
private isIdle = false
|
|
39
|
+
private updateInterval: ReturnType<typeof setInterval> | null = null
|
|
40
|
+
private idleCheckInterval: ReturnType<typeof setInterval> | null = null
|
|
41
|
+
|
|
42
|
+
constructor(config: Partial<TimeTrackerConfig> = {}) {
|
|
43
|
+
this.config = { ...DEFAULT_CONFIG, ...config }
|
|
44
|
+
this.startTime = Date.now()
|
|
45
|
+
this.lastActivityTime = this.startTime
|
|
46
|
+
this.lastUpdateTime = this.startTime
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Start tracking time
|
|
51
|
+
*/
|
|
52
|
+
start(): void {
|
|
53
|
+
// Listen for visibility changes
|
|
54
|
+
document.addEventListener('visibilitychange', this.handleVisibility)
|
|
55
|
+
|
|
56
|
+
// Listen for user activity
|
|
57
|
+
const activityEvents = ['mousemove', 'keydown', 'scroll', 'click', 'touchstart']
|
|
58
|
+
activityEvents.forEach(event => {
|
|
59
|
+
document.addEventListener(event, this.handleActivity, { passive: true })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Start update interval
|
|
63
|
+
this.updateInterval = setInterval(() => {
|
|
64
|
+
this.update()
|
|
65
|
+
}, this.config.updateIntervalMs)
|
|
66
|
+
|
|
67
|
+
// Start idle check
|
|
68
|
+
this.idleCheckInterval = setInterval(() => {
|
|
69
|
+
this.checkIdle()
|
|
70
|
+
}, 1000)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Stop tracking
|
|
75
|
+
*/
|
|
76
|
+
stop(): void {
|
|
77
|
+
document.removeEventListener('visibilitychange', this.handleVisibility)
|
|
78
|
+
|
|
79
|
+
const activityEvents = ['mousemove', 'keydown', 'scroll', 'click', 'touchstart']
|
|
80
|
+
activityEvents.forEach(event => {
|
|
81
|
+
document.removeEventListener(event, this.handleActivity)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
if (this.updateInterval) {
|
|
85
|
+
clearInterval(this.updateInterval)
|
|
86
|
+
this.updateInterval = null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (this.idleCheckInterval) {
|
|
90
|
+
clearInterval(this.idleCheckInterval)
|
|
91
|
+
this.idleCheckInterval = null
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get current time metrics
|
|
97
|
+
*/
|
|
98
|
+
getMetrics(): TimeEvent {
|
|
99
|
+
this.updateTimes()
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
active_time_ms: this.activeTime,
|
|
103
|
+
total_time_ms: Date.now() - this.startTime,
|
|
104
|
+
idle_time_ms: this.idleTime,
|
|
105
|
+
is_engaged: !this.isIdle && this.isVisible,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get final metrics (for unload)
|
|
111
|
+
*/
|
|
112
|
+
getFinalMetrics(): TimeEvent {
|
|
113
|
+
this.updateTimes()
|
|
114
|
+
return this.getMetrics()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private handleVisibility = (): void => {
|
|
118
|
+
const wasVisible = this.isVisible
|
|
119
|
+
this.isVisible = document.visibilityState === 'visible'
|
|
120
|
+
|
|
121
|
+
if (wasVisible && !this.isVisible) {
|
|
122
|
+
// Tab hidden - update times before stopping
|
|
123
|
+
this.updateTimes()
|
|
124
|
+
} else if (!wasVisible && this.isVisible) {
|
|
125
|
+
// Tab shown - resume tracking
|
|
126
|
+
this.lastUpdateTime = Date.now()
|
|
127
|
+
this.lastActivityTime = Date.now()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private handleActivity = (): void => {
|
|
132
|
+
const now = Date.now()
|
|
133
|
+
|
|
134
|
+
if (this.isIdle) {
|
|
135
|
+
// Was idle, now active
|
|
136
|
+
this.isIdle = false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.lastActivityTime = now
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private checkIdle(): void {
|
|
143
|
+
const now = Date.now()
|
|
144
|
+
const timeSinceActivity = now - this.lastActivityTime
|
|
145
|
+
|
|
146
|
+
if (!this.isIdle && timeSinceActivity >= this.config.idleThresholdMs) {
|
|
147
|
+
// User went idle
|
|
148
|
+
this.isIdle = true
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private updateTimes(): void {
|
|
153
|
+
const now = Date.now()
|
|
154
|
+
const elapsed = now - this.lastUpdateTime
|
|
155
|
+
|
|
156
|
+
if (this.isVisible) {
|
|
157
|
+
if (this.isIdle) {
|
|
158
|
+
this.idleTime += elapsed
|
|
159
|
+
} else {
|
|
160
|
+
this.activeTime += elapsed
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.lastUpdateTime = now
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private update(): void {
|
|
168
|
+
if (!this.isVisible) return
|
|
169
|
+
|
|
170
|
+
this.updateTimes()
|
|
171
|
+
this.config.onUpdate?.(this.getMetrics())
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|