-
Notifications
You must be signed in to change notification settings - Fork 43
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add foregroundTimeTracker class
- Loading branch information
Showing
12 changed files
with
277 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
export default class ForegroundTimeTracker { | ||
private isTrackerActive: boolean = false; | ||
private localStorageName: string = ''; | ||
public startTime: number = 0; | ||
public totalTime: number = 0; | ||
|
||
constructor(apiKey: string) { | ||
this.localStorageName = `mp-time-${apiKey}`; | ||
this.loadTimeFromStorage(); | ||
this.handleVisibilityChange = this.handleVisibilityChange.bind(this); | ||
this.syncAcrossTabs = this.syncAcrossTabs.bind(this); | ||
this.init(); | ||
} | ||
|
||
private init(): void { | ||
document.addEventListener( | ||
'visibilitychange', | ||
this.handleVisibilityChange | ||
); | ||
window.addEventListener('beforeunload', () => this.updateTimeInPersistence()); | ||
// Sync time updates across tabs | ||
window.addEventListener('storage', this.syncAcrossTabs); | ||
this.startTracking(); | ||
|
||
// TODO: this is just to ensure when we load it in an app we can see the timer updates in the console | ||
setInterval(() => { | ||
console.log(this.startTime); | ||
console.log(this.getTimeInForeground()); | ||
}, 1000); | ||
} | ||
|
||
private loadTimeFromStorage(): void { | ||
const storedTime = localStorage.getItem(this.localStorageName); | ||
if (storedTime !== null) { | ||
this.totalTime = parseFloat(storedTime); | ||
} | ||
} | ||
|
||
private startTracking(): void { | ||
if (!document.hidden) { | ||
this.startTime = Math.floor(performance.now()); | ||
this.isTrackerActive = true; | ||
} | ||
} | ||
|
||
private stopTracking(): void { | ||
if (this.isTrackerActive) { | ||
this.setTotalTime(); | ||
this.updateTimeInPersistence(); | ||
this.isTrackerActive = false; | ||
} | ||
} | ||
|
||
private setTotalTime(): void { | ||
if (this.isTrackerActive) { | ||
const now = Math.floor(performance.now()); | ||
this.totalTime += now - this.startTime; | ||
this.startTime = now; | ||
} | ||
} | ||
|
||
public updateTimeInPersistence(): void { | ||
if (this.isTrackerActive) { | ||
localStorage.setItem( | ||
this.localStorageName, | ||
this.totalTime.toFixed(0) | ||
); | ||
} | ||
} | ||
|
||
private handleVisibilityChange(): void { | ||
if (document.hidden) { | ||
this.stopTracking(); | ||
} else { | ||
this.startTracking(); | ||
} | ||
} | ||
|
||
private syncAcrossTabs(event: StorageEvent): void { | ||
if (event.key === this.localStorageName && event.newValue !== null) { | ||
const newTime = parseFloat(event.newValue) || 0; | ||
// do not overwrite if the new time is smaller than the previous totalTime | ||
if (newTime > this.totalTime) { | ||
this.totalTime = newTime; | ||
|
||
} | ||
} | ||
} | ||
|
||
public getTimeInForeground(): number { | ||
this.setTotalTime(); | ||
this.updateTimeInPersistence(); | ||
return this.totalTime; | ||
} | ||
|
||
public resetTimer(): void { | ||
this.totalTime = 0; | ||
this.updateTimeInPersistence(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
import ForegroundTimeTracker from '../../src/foregroundTimeTracker'; | ||
|
||
describe('ForegroundTimeTracker', () => { | ||
let foregroundTimeTracker: ForegroundTimeTracker; | ||
const apiKey = 'test-key'; | ||
const mockStorageKey = `mp-time-${apiKey}`; | ||
|
||
beforeEach(() => { | ||
Object.defineProperty(document, 'hidden', { value: false, configurable: true }); | ||
jest.useFakeTimers(); | ||
localStorage.clear(); | ||
}); | ||
|
||
afterEach(() => { | ||
jest.clearAllTimers(); | ||
jest.restoreAllMocks(); | ||
}); | ||
|
||
// in Jest, document.hidden by default is false | ||
it('should initialize with correct localStorage key', () => { | ||
foregroundTimeTracker = new ForegroundTimeTracker(apiKey); | ||
expect(foregroundTimeTracker['localStorageName']).toBe(mockStorageKey); | ||
}); | ||
|
||
it('should load time from localStorage on initialization', () => { | ||
localStorage.setItem(mockStorageKey, '1000'); | ||
foregroundTimeTracker = new ForegroundTimeTracker(apiKey); | ||
expect(foregroundTimeTracker['totalTime']).toBe(1000); | ||
}); | ||
|
||
it('should start tracking when the page is visible', () => { | ||
Object.defineProperty(document, 'hidden', { value: false, configurable: true }); | ||
jest.spyOn(global.performance, 'now') | ||
.mockReturnValueOnce(1000); | ||
foregroundTimeTracker = new ForegroundTimeTracker(apiKey); | ||
|
||
expect(foregroundTimeTracker['isTrackerActive']).toBe(true); | ||
expect(foregroundTimeTracker['startTime']).toBe(1000); | ||
}); | ||
|
||
it('should not start tracking if the page is hidden', () => { | ||
Object.defineProperty(document, 'hidden', { value: true }); | ||
jest.spyOn(global.performance, 'now') | ||
.mockReturnValueOnce(1000); | ||
foregroundTimeTracker = new ForegroundTimeTracker(apiKey); | ||
expect(foregroundTimeTracker['isTrackerActive']).toBe(false); | ||
|
||
// since the page is hidden, it does not call performance.now | ||
expect(foregroundTimeTracker['startTime']).toBe(0); | ||
}); | ||
|
||
it('should stop tracking when the page becomes hidden', () => { | ||
jest.spyOn(global.performance, 'now') | ||
.mockReturnValueOnce(1000) | ||
.mockReturnValueOnce(2000) | ||
.mockReturnValueOnce(3000); | ||
|
||
Object.defineProperty(document, 'hidden', { value: false, configurable: true }); | ||
|
||
foregroundTimeTracker = new ForegroundTimeTracker(apiKey); | ||
|
||
expect(foregroundTimeTracker['totalTime']).toBe(0); | ||
expect(foregroundTimeTracker['isTrackerActive']).toBe(true); | ||
foregroundTimeTracker['handleVisibilityChange'](); | ||
expect(foregroundTimeTracker['isTrackerActive']).toBe(true); | ||
|
||
Object.defineProperty(document, 'hidden', { value: true, configurable: true }); | ||
foregroundTimeTracker['handleVisibilityChange'](); | ||
expect(foregroundTimeTracker['isTrackerActive']).toBe(false); | ||
}); | ||
|
||
it('should resume tracking when the page becomes visible again', () => { | ||
Object.defineProperty(document, 'hidden', { value: false, configurable: true }); | ||
foregroundTimeTracker = new ForegroundTimeTracker(apiKey); | ||
expect(foregroundTimeTracker['isTrackerActive']).toBe(true); | ||
|
||
Object.defineProperty(document, 'hidden', { value: true, configurable: true }); | ||
foregroundTimeTracker['handleVisibilityChange'](); | ||
expect(foregroundTimeTracker['isTrackerActive']).toBe(false); | ||
|
||
Object.defineProperty(document, 'hidden', { value: false, configurable: true }); | ||
foregroundTimeTracker['handleVisibilityChange'](); | ||
expect(foregroundTimeTracker['isTrackerActive']).toBe(true); | ||
}); | ||
|
||
it('should correctly calculate time in foreground', () => { | ||
jest.spyOn(global.performance, 'now') | ||
.mockReturnValueOnce(10) | ||
.mockReturnValueOnce(50) | ||
foregroundTimeTracker = new ForegroundTimeTracker(apiKey); | ||
|
||
expect(foregroundTimeTracker.getTimeInForeground()).toBe(40); | ||
}); | ||
|
||
it('should update time in localStorage and totalTimewhen the page is hidden', () => { | ||
jest.spyOn(global.performance, 'now') | ||
.mockReturnValueOnce(10) | ||
.mockReturnValueOnce(50) | ||
foregroundTimeTracker = new ForegroundTimeTracker(apiKey); | ||
foregroundTimeTracker['totalTime'] = 1000; | ||
|
||
Object.defineProperty(document, 'hidden', { value: true, configurable: true }); | ||
foregroundTimeTracker['handleVisibilityChange'](); | ||
expect(foregroundTimeTracker['isTrackerActive']).toBe(false); | ||
expect(localStorage.getItem(mockStorageKey)).toBe('1040'); | ||
expect(foregroundTimeTracker['totalTime']).toBe(1040); | ||
}); | ||
|
||
it('should set startTime to the last performance.now call when stopping tracking', () => { | ||
jest.spyOn(global.performance, 'now') | ||
.mockReturnValueOnce(10) | ||
.mockReturnValueOnce(50) | ||
foregroundTimeTracker = new ForegroundTimeTracker(apiKey); | ||
foregroundTimeTracker['stopTracking'](); | ||
expect(foregroundTimeTracker['startTime']).toBe(50); | ||
}); | ||
|
||
it('should update totalTime from localStorage when storage event occurs', () => { | ||
localStorage.setItem(mockStorageKey, '7000'); | ||
const event = new StorageEvent('storage', { | ||
key: mockStorageKey, | ||
newValue: '7000', | ||
}); | ||
|
||
foregroundTimeTracker = new ForegroundTimeTracker(apiKey); | ||
|
||
foregroundTimeTracker['syncAcrossTabs'](event); | ||
expect(foregroundTimeTracker['totalTime']).toBe(7000); | ||
}); | ||
|
||
it('should not overwrite totalTime if the new value is smaller', () => { | ||
foregroundTimeTracker = new ForegroundTimeTracker(apiKey); | ||
|
||
foregroundTimeTracker['totalTime'] = 8000; | ||
const event = new StorageEvent('storage', { | ||
key: mockStorageKey, | ||
newValue: '3000', | ||
}); | ||
|
||
foregroundTimeTracker['syncAcrossTabs'](event); | ||
expect(foregroundTimeTracker['totalTime']).toBe(8000); | ||
}); | ||
|
||
it('should reset totalTime and update localStorage', () => { | ||
foregroundTimeTracker = new ForegroundTimeTracker(apiKey); | ||
|
||
foregroundTimeTracker['totalTime'] = 5000; | ||
foregroundTimeTracker.resetTimer(); | ||
expect(foregroundTimeTracker['totalTime']).toBe(0); | ||
expect(localStorage.getItem(mockStorageKey)).toBe('0'); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.