Skip to content

Commit 944f03a

Browse files
committed
feat: Add foregroundTimeTracker class
1 parent 070d690 commit 944f03a

11 files changed

+228
-26
lines changed

src/foregroundTimeTracker.ts

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
export default class ForegroundTimeTracker {
2-
private isActive: boolean = false;
2+
private isTrackerActive: boolean = false;
33
private localStorageName: string = '';
4-
private startTime: number = 0;
5-
private totalTime: number = parseFloat(
6-
localStorage.getItem(this.localStorageName) || '0'
7-
);
4+
public startTime: number = 0;
5+
public totalTime: number = 0;
86

9-
constructor(apiKey) {
7+
constructor(apiKey: string) {
8+
this.localStorageName = `mp-time-${apiKey}`;
9+
this.loadTimeFromStorage();
1010
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
1111
this.syncAcrossTabs = this.syncAcrossTabs.bind(this);
12-
this.localStorageName = `mp-time-${apiKey}`;
1312
this.init();
1413
}
1514

@@ -18,36 +17,54 @@ export default class ForegroundTimeTracker {
1817
'visibilitychange',
1918
this.handleVisibilityChange
2019
);
21-
window.addEventListener('beforeunload', () => this.updateTime());
22-
window.addEventListener('storage', this.syncAcrossTabs); // Sync updates across tabs
20+
window.addEventListener('beforeunload', () => this.updateTimeInPersistence());
21+
// Sync time updates across tabs
22+
window.addEventListener('storage', this.syncAcrossTabs);
2323
this.startTracking();
24+
25+
// TODO: this is just to ensure when we load it in an app we can see the timer updates in the console
26+
setInterval(() => {
27+
console.log(this.startTime);
28+
console.log(this.getTimeInForeground());
29+
}, 1000);
30+
}
31+
32+
private loadTimeFromStorage(): void {
33+
const storedTime = localStorage.getItem(this.localStorageName);
34+
if (storedTime !== null) {
35+
this.totalTime = parseFloat(storedTime);
36+
}
2437
}
2538

2639
private startTracking(): void {
2740
if (!document.hidden) {
28-
this.startTime = performance.now();
29-
this.isActive = true;
30-
setInterval(() => {
31-
console.log(this.totalTime);
32-
});
41+
this.startTime = Math.floor(performance.now());
42+
this.isTrackerActive = true;
3343
}
3444
}
3545

3646
private stopTracking(): void {
37-
if (this.isActive) {
38-
this.updateTime();
39-
this.isActive = false;
47+
if (this.isTrackerActive) {
48+
this.setTotalTime();
49+
this.updateTimeInPersistence();
50+
this.isTrackerActive = false;
4051
}
4152
}
4253

43-
private updateTime(): void {
44-
if (this.isActive) {
45-
this.totalTime += (performance.now() - this.startTime) / 1000; // Convert ms to seconds
54+
private setTotalTime(): void {
55+
if (this.isTrackerActive) {
56+
const now = Math.floor(performance.now());
57+
this.totalTime += now - this.startTime;
58+
this.startTime = now;
59+
}
60+
}
61+
62+
public updateTimeInPersistence(): void {
63+
if (this.isTrackerActive) {
4664
localStorage.setItem(
4765
this.localStorageName,
48-
this.totalTime.toFixed(2)
66+
this.totalTime.toFixed(0)
4967
);
50-
this.startTime = 0;
5168
}
5269
}
5370

@@ -61,12 +78,23 @@ export default class ForegroundTimeTracker {
6178

6279
private syncAcrossTabs(event: StorageEvent): void {
6380
if (event.key === this.localStorageName && event.newValue !== null) {
64-
this.totalTime = parseFloat(event.newValue) || 0;
81+
const newTime = parseFloat(event.newValue) || 0;
82+
// do not overwrite if the new time is smaller than the previous totalTime
83+
if (newTime > this.totalTime) {
84+
this.totalTime = newTime;
85+
86+
}
6587
}
6688
}
6789

68-
public getTimeInForeground(): string {
69-
this.updateTime();
70-
return `${this.totalTime.toFixed(2)} seconds`;
90+
public getTimeInForeground(): number {
91+
this.setTotalTime();
92+
this.updateTimeInPersistence();
93+
return this.totalTime;
94+
}
95+
96+
public resetTimer(): void {
97+
this.totalTime = 0;
98+
this.updateTimeInPersistence();
7199
}
72100
}

src/sdkRuntimeModels.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ export interface SDKEvent {
7979
DataPlan?: SDKDataPlan;
8080
LaunchReferral?: string;
8181
ExpandedEventCount: number;
82+
ActiveTimeOnSite: number;
8283
}
84+
8385
export interface SDKGeoLocation {
8486
lat: number | string;
8587
lng: number | string;

src/sdkToEventsApiConverter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,7 @@ export function convertBaseEventData(
658658
custom_attributes: sdkEvent.EventAttributes,
659659
location: convertSDKLocation(sdkEvent.Location),
660660
source_message_id: sdkEvent.SourceMessageId,
661+
active_time_on_site_ms: sdkEvent.ActiveTimeOnSite
661662
};
662663

663664
return commonEventData;

src/serverModel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ export default function ServerModel(
345345
event.data,
346346
event.name
347347
),
348+
ActiveTimeOnSite: mpInstance._timer.getTimeInForeground(),
348349
SourceMessageId:
349350
event.sourceMessageId ||
350351
mpInstance._Helpers.generateUniqueId(),

src/sessionManager.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export default function SessionManager(
132132
});
133133

134134
mpInstance._Store.nullifySession();
135+
mpInstance._timer.resetTimer();
135136
return;
136137
}
137138

@@ -180,6 +181,9 @@ export default function SessionManager(
180181
mpInstance._Store.nullifySession();
181182
}
182183
}
184+
185+
mpInstance._timer.resetTimer();
186+
183187
};
184188

185189
this.setSessionTimer = function(): void {

test/fixtures/events.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const event0: SDKEvent = {
2020
Debug: false,
2121
DeviceId: 'test-device',
2222
Timestamp: 0,
23+
ActiveTimeOnSite: 10
2324
};
2425

2526
export const event1: SDKEvent = {
@@ -42,6 +43,7 @@ export const event1: SDKEvent = {
4243
Debug: false,
4344
DeviceId: 'test-device',
4445
Timestamp: 0,
46+
ActiveTimeOnSite: 20
4547
};
4648

4749
export const event2: SDKEvent = {
@@ -64,6 +66,7 @@ export const event2: SDKEvent = {
6466
Debug: false,
6567
DeviceId: 'test-device',
6668
Timestamp: 0,
69+
ActiveTimeOnSite: 30
6770
};
6871

6972
export const event3: SDKEvent = {
@@ -86,4 +89,5 @@ export const event3: SDKEvent = {
8689
Debug: false,
8790
DeviceId: 'test-device',
8891
Timestamp: 0,
92+
ActiveTimeOnSite: 40
8993
};
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import ForegroundTimeTracker from '../../src/foregroundTimeTracker';
2+
3+
describe('ForegroundTimeTracker', () => {
4+
let foregroundTimeTracker: ForegroundTimeTracker;
5+
const apiKey = 'test-key';
6+
const mockStorageKey = `mp-time-${apiKey}`;
7+
8+
beforeEach(() => {
9+
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
10+
jest.useFakeTimers();
11+
localStorage.clear();
12+
});
13+
14+
afterEach(() => {
15+
jest.clearAllTimers();
16+
jest.restoreAllMocks();
17+
});
18+
19+
// in Jest, document.hidden by default is false
20+
it('should initialize with correct localStorage key', () => {
21+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
22+
expect(foregroundTimeTracker['localStorageName']).toBe(mockStorageKey);
23+
});
24+
25+
it('should load time from localStorage on initialization', () => {
26+
localStorage.setItem(mockStorageKey, '1000');
27+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
28+
expect(foregroundTimeTracker['totalTime']).toBe(1000);
29+
});
30+
31+
it('should start tracking when the page is visible', () => {
32+
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
33+
jest.spyOn(global.performance, 'now')
34+
.mockReturnValueOnce(1000);
35+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
36+
37+
expect(foregroundTimeTracker['isTrackerActive']).toBe(true);
38+
expect(foregroundTimeTracker['startTime']).toBe(1000);
39+
});
40+
41+
it('should not start tracking if the page is hidden', () => {
42+
Object.defineProperty(document, 'hidden', { value: true });
43+
jest.spyOn(global.performance, 'now')
44+
.mockReturnValueOnce(1000);
45+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
46+
expect(foregroundTimeTracker['isTrackerActive']).toBe(false);
47+
48+
// since the page is hidden, it does not call performance.now
49+
expect(foregroundTimeTracker['startTime']).toBe(0);
50+
});
51+
52+
it('should stop tracking when the page becomes hidden', () => {
53+
jest.spyOn(global.performance, 'now')
54+
.mockReturnValueOnce(1000)
55+
.mockReturnValueOnce(2000)
56+
.mockReturnValueOnce(3000);
57+
58+
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
59+
60+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
61+
62+
expect(foregroundTimeTracker['totalTime']).toBe(0);
63+
expect(foregroundTimeTracker['isTrackerActive']).toBe(true);
64+
foregroundTimeTracker['handleVisibilityChange']();
65+
expect(foregroundTimeTracker['isTrackerActive']).toBe(true);
66+
67+
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
68+
foregroundTimeTracker['handleVisibilityChange']();
69+
expect(foregroundTimeTracker['isTrackerActive']).toBe(false);
70+
});
71+
72+
it('should resume tracking when the page becomes visible again', () => {
73+
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
74+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
75+
expect(foregroundTimeTracker['isTrackerActive']).toBe(true);
76+
77+
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
78+
foregroundTimeTracker['handleVisibilityChange']();
79+
expect(foregroundTimeTracker['isTrackerActive']).toBe(false);
80+
81+
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
82+
foregroundTimeTracker['handleVisibilityChange']();
83+
expect(foregroundTimeTracker['isTrackerActive']).toBe(true);
84+
});
85+
86+
it('should correctly calculate time in foreground', () => {
87+
jest.spyOn(global.performance, 'now')
88+
.mockReturnValueOnce(10)
89+
.mockReturnValueOnce(50)
90+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
91+
92+
expect(foregroundTimeTracker.getTimeInForeground()).toBe(40);
93+
});
94+
95+
it('should update time in localStorage and totalTimewhen the page is hidden', () => {
96+
jest.spyOn(global.performance, 'now')
97+
.mockReturnValueOnce(10)
98+
.mockReturnValueOnce(50)
99+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
100+
foregroundTimeTracker['totalTime'] = 1000;
101+
102+
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
103+
foregroundTimeTracker['handleVisibilityChange']();
104+
expect(foregroundTimeTracker['isTrackerActive']).toBe(false);
105+
expect(localStorage.getItem(mockStorageKey)).toBe('1040');
106+
expect(foregroundTimeTracker['totalTime']).toBe(1040);
107+
});
108+
109+
it('should set startTime to the last performance.now call when stopping tracking', () => {
110+
jest.spyOn(global.performance, 'now')
111+
.mockReturnValueOnce(10)
112+
.mockReturnValueOnce(50)
113+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
114+
foregroundTimeTracker['stopTracking']();
115+
expect(foregroundTimeTracker['startTime']).toBe(50);
116+
});
117+
118+
it('should update totalTime from localStorage when storage event occurs', () => {
119+
localStorage.setItem(mockStorageKey, '7000');
120+
const event = new StorageEvent('storage', {
121+
key: mockStorageKey,
122+
newValue: '7000',
123+
});
124+
125+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
126+
127+
foregroundTimeTracker['syncAcrossTabs'](event);
128+
expect(foregroundTimeTracker['totalTime']).toBe(7000);
129+
});
130+
131+
it('should not overwrite totalTime if the new value is smaller', () => {
132+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
133+
134+
foregroundTimeTracker['totalTime'] = 8000;
135+
const event = new StorageEvent('storage', {
136+
key: mockStorageKey,
137+
newValue: '3000',
138+
});
139+
140+
foregroundTimeTracker['syncAcrossTabs'](event);
141+
expect(foregroundTimeTracker['totalTime']).toBe(8000);
142+
});
143+
144+
it('should reset totalTime and update localStorage', () => {
145+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
146+
147+
foregroundTimeTracker['totalTime'] = 5000;
148+
foregroundTimeTracker.resetTimer();
149+
expect(foregroundTimeTracker['totalTime']).toBe(0);
150+
expect(localStorage.getItem(mockStorageKey)).toBe('0');
151+
});
152+
});

test/src/tests-batchUploader.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ describe('batch uploader', () => {
7676
Debug: false,
7777
DeviceId: 'test-device',
7878
Timestamp: 0,
79+
ActiveTimeOnSite: 10
7980
};
8081

8182
uploader.queueEvent(event);
@@ -454,6 +455,7 @@ describe('batch uploader', () => {
454455
Debug: false,
455456
DeviceId: 'test-device',
456457
Timestamp: 0,
458+
ActiveTimeOnSite: 10
457459
};
458460

459461
const expectedEvent = [event];
@@ -515,6 +517,7 @@ describe('batch uploader', () => {
515517
Debug: false,
516518
DeviceId: 'test-device',
517519
Timestamp: 0,
520+
ActiveTimeOnSite: 10
518521
};
519522

520523
uploader.queueEvent(event);

test/src/tests-kit-blocking.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ describe('kit blocking', () => {
115115
EventDataType: null,
116116
Debug: true,
117117
CurrencyCode: 'usd',
118+
ActiveTimeOnSite: 10
118119
}
119120
});
120121

@@ -228,6 +229,7 @@ describe('kit blocking', () => {
228229
EventDataType: null,
229230
Debug: true,
230231
CurrencyCode: 'usd',
232+
ActiveTimeOnSite: 10
231233
};
232234
});
233235

@@ -440,6 +442,7 @@ describe('kit blocking', () => {
440442
EventDataType: null,
441443
Debug: true,
442444
CurrencyCode: 'usd',
445+
ActiveTimeOnSite: 10
443446
};
444447
});
445448

0 commit comments

Comments
 (0)