Skip to content

Commit a885dd5

Browse files
committed
feat(analytics): implement extensible reporting and dashboard architecture
- add centralized analytics ingestion pipeline via background proxy - introduce subscription policy engine with plans, capabilities, and limits - add formatter and reporting layers for clean UI-facing analytics - implement dashboard analytics cards with trends and session metrics - refactor session tracking to use continuous heartbeat updates - decouple analytics reporting from UI and storage internals - add formatter unit tests and subscription-aware service tests
1 parent 7aed704 commit a885dd5

21 files changed

Lines changed: 466 additions & 34 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { AnalyticsRepository } from '../../core/interfaces/analytics-repository';
2+
import { AnalyticsRecord, AnalyticsQueryFilter } from '../../core/analytics/types';
3+
import { AppAction } from '../../types';
4+
5+
export class ChromeProxyAnalyticsRepository implements AnalyticsRepository {
6+
public async saveRecord(record: AnalyticsRecord): Promise<void> {
7+
chrome.runtime.sendMessage({ action: AppAction.SAVE_ANALYTICS, record }).catch(() => {});
8+
}
9+
public async queryRecords(_filter: AnalyticsQueryFilter): Promise<AnalyticsRecord[]> {
10+
return [];
11+
}
12+
public async clearRecordsOlderThan(_timestamp: number): Promise<void> {}
13+
}

src/background.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { StorageFacade } from './core/storage/index';
22
import { ChromeStorageDriver } from './adapters/chrome/storage-driver';
3-
import { PlatformId, PLATFORMS_CONFIG } from './types';
3+
import { PlatformId, PLATFORMS_CONFIG, AppAction } from './types';
44
import { ChromeAlarmManager } from './adapters/chrome/alarm-manager';
55
import { ChromeTabManager } from './adapters/chrome/tab-manager';
66
import { ChromeMessageBus } from './adapters/chrome/message-bus';
77
import { BackgroundOrchestrator } from './core/background-orchestrator';
88
import { IndexedDbAnalyticsRepository } from './adapters/browser/indexeddb-analytics';
9+
import { AnalyticsRecord } from './core/analytics/types';
910

1011
const alarmManager = new ChromeAlarmManager();
1112
const tabManager = new ChromeTabManager();
@@ -95,7 +96,15 @@ chrome.runtime.onInstalled.addListener(async () => {
9596
chrome.alarms.onAlarm.addListener(async (alarm) => {
9697
if (alarm.name === GC_ALARM_NAME) {
9798
if (storage.analyticsService) {
98-
await storage.analyticsService.performGarbageCollection(7);
99+
await storage.analyticsService.performGarbageCollection();
100+
}
101+
}
102+
});
103+
104+
chrome.runtime.onMessage.addListener((message) => {
105+
if (message.action === AppAction.SAVE_ANALYTICS && message.record) {
106+
if (storage.analyticsService) {
107+
void storage.analyticsService.saveRecord(message.record as AnalyticsRecord);
99108
}
100109
}
101110
});

src/content.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ChromeMessageBus } from './adapters/chrome/message-bus';
55
import { ChromeConnectionManager } from './adapters/chrome/connection-manager';
66
import { ChromeStorageDriver } from './adapters/chrome/storage-driver';
77
import { StorageFacade } from './core/storage/index';
8-
import { IndexedDbAnalyticsRepository } from './adapters/browser/indexeddb-analytics';
8+
import { ChromeProxyAnalyticsRepository } from './adapters/chrome/proxy-analytics';
99

1010
declare global {
1111
interface Window {
@@ -20,7 +20,7 @@ if (!window.__LIMITRA_INJECTED__) {
2020
const storageDriver = new ChromeStorageDriver();
2121
const storage = new StorageFacade(storageDriver);
2222

23-
storage.setAnalyticsRepository(new IndexedDbAnalyticsRepository());
23+
storage.setAnalyticsRepository(new ChromeProxyAnalyticsRepository());
2424

2525
const messageBus = new ChromeMessageBus();
2626
const connectionManager = new ChromeConnectionManager();

src/core/analytics/aggregator.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,12 @@ export class AnalyticsAggregator {
3434
}
3535
return grouped;
3636
}
37+
38+
/**
39+
* Abstracting the session calculation logic to protect us in case
40+
* the definition of a session changes in the future
41+
*/
42+
public static calculateSessionCount(records: AnalyticsRecord[]): number {
43+
return records.length;
44+
}
3745
}

src/core/analytics/formatter.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
export interface TimeTranslations {
2+
hours: string;
3+
minutes: string;
4+
}
5+
6+
export class AnalyticsFormatter {
7+
/**
8+
* Convert milliseconds to a readable format (e.g., 2h 15m or 45m)
9+
*/
10+
public static msToReadable(
11+
ms: number,
12+
t: TimeTranslations = { hours: 'h', minutes: 'm' },
13+
): string {
14+
const totalMinutes = Math.floor(ms / (1000 * 60));
15+
if (totalMinutes < 1) return `0${t.minutes}`;
16+
17+
const hours = Math.floor(totalMinutes / 60);
18+
const mins = totalMinutes % 60;
19+
20+
if (hours > 0) {
21+
return mins > 0 ? `${hours}${t.hours} ${mins}${t.minutes}` : `${hours}${t.hours}`;
22+
}
23+
return `${mins}${t.minutes}`;
24+
}
25+
26+
/**
27+
* Calculate the percentage change between two periods (e.g., today compared to yesterday)
28+
* A positive result indicates an increase in usage, and a negative one indicates a decrease (improvement).
29+
*/
30+
public static calculateTrendPercent(oldMs: number, newMs: number): number {
31+
if (oldMs === 0) return newMs > 0 ? 100 : 0;
32+
const difference = newMs - oldMs;
33+
return Math.round((difference / oldMs) * 100);
34+
}
35+
36+
/**
37+
* Convert percentage to a formatted string with a sign (e.g., +15% or -32%)
38+
*/
39+
public static formatTrend(trendPercent: number): string {
40+
if (trendPercent > 0) return `+${trendPercent}%`;
41+
if (trendPercent < 0) return `${trendPercent}%`;
42+
return '0%';
43+
}
44+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { AnalyticsRepository } from '../../interfaces/analytics-repository';
2+
import { AnalyticsAggregator } from '../aggregator';
3+
import { AnalyticsFormatter, TimeTranslations } from '../formatter';
4+
5+
export interface DashboardReportResult {
6+
raw: {
7+
todayMs: number;
8+
yesterdayMs: number;
9+
todaySessions: number;
10+
};
11+
formatted: {
12+
todayReadable: string;
13+
trendText: string;
14+
isImproving: boolean;
15+
};
16+
}
17+
18+
export class DashboardReportGenerator {
19+
constructor(private repo: AnalyticsRepository) {}
20+
21+
private async getUsageForDay(platformId: string, startOfDayMs: number) {
22+
const endOfDayMs = startOfDayMs + 24 * 60 * 60 * 1000 - 1;
23+
const records = await this.repo.queryRecords({
24+
platformId: platformId,
25+
fromTime: startOfDayMs,
26+
toTime: endOfDayMs,
27+
});
28+
29+
return {
30+
duration: AnalyticsAggregator.calculateTotalDuration(records),
31+
sessions: AnalyticsAggregator.calculateSessionCount(records),
32+
};
33+
}
34+
35+
public async generate(
36+
platformId: string,
37+
translations: TimeTranslations,
38+
): Promise<DashboardReportResult> {
39+
const now = new Date();
40+
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
41+
const yesterdayStart = todayStart - 24 * 60 * 60 * 1000;
42+
43+
const todayData = await this.getUsageForDay(platformId, todayStart);
44+
const yesterdayData = await this.getUsageForDay(platformId, yesterdayStart);
45+
46+
const trendPercent = AnalyticsFormatter.calculateTrendPercent(
47+
yesterdayData.duration,
48+
todayData.duration,
49+
);
50+
51+
return {
52+
raw: {
53+
todayMs: todayData.duration,
54+
yesterdayMs: yesterdayData.duration,
55+
todaySessions: todayData.sessions,
56+
},
57+
formatted: {
58+
todayReadable: AnalyticsFormatter.msToReadable(todayData.duration, translations),
59+
trendText: AnalyticsFormatter.formatTrend(trendPercent),
60+
isImproving: trendPercent <= 0,
61+
},
62+
};
63+
}
64+
}

src/core/analytics/service.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
1-
import { AnalyticsRepository } from '../interfaces/analytics-repository';
21
import { AnalyticsAggregator } from './aggregator';
2+
import { AnalyticsRepository } from '../interfaces/analytics-repository';
3+
import { SubscriptionService } from '../subscription/service';
4+
import { LimitType } from '../subscription/limits';
5+
import { DashboardReportGenerator } from './reports/dashboard-report';
6+
import { TimeTranslations } from './formatter';
37

48
export class AnalyticsService {
5-
constructor(private repo: AnalyticsRepository) {}
9+
private dashboardReport: DashboardReportGenerator;
10+
11+
constructor(
12+
private repo: AnalyticsRepository,
13+
private subscriptionService: SubscriptionService,
14+
) {
15+
this.dashboardReport = new DashboardReportGenerator(this.repo);
16+
}
617

718
/**
819
* Retrieves the total usage time for the current day
@@ -81,7 +92,9 @@ export class AnalyticsService {
8192
* Removes old analytics data to save storage space (Garbage Collection).
8293
* By default, records older than 7 days will be deleted.
8394
*/
84-
public async performGarbageCollection(retentionDays: number = 7): Promise<void> {
95+
public async performGarbageCollection(): Promise<void> {
96+
const retentionDays = this.subscriptionService.getLimit(LimitType.ANALYTICS_RETENTION_DAYS);
97+
8598
const cutoffTime = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
8699

87100
await this.repo.clearRecordsOlderThan(cutoffTime);
@@ -90,4 +103,15 @@ export class AnalyticsService {
90103
`[Limitra Analytics] Garbage collection completed. Cleared records older than ${retentionDays} days.`,
91104
);
92105
}
106+
107+
/**
108+
* Delegate dashboard report generation to the reporting layer
109+
*/
110+
public async getDashboardReport(platformId: string, translations: TimeTranslations) {
111+
return this.dashboardReport.generate(platformId, translations);
112+
}
113+
114+
public async saveRecord(record: import('./types').AnalyticsRecord): Promise<void> {
115+
return this.repo.saveRecord(record);
116+
}
93117
}

src/core/session.ts

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ export class SessionManager {
88
private adapter: PlatformAdapter;
99
private lastActivityTime = Date.now();
1010
private lastHeartbeat = Date.now();
11-
private lastSyncTime = Date.now();
1211
private isTracking = false;
1312
private isBlocked = false;
1413
private heartbeatId: ReturnType<typeof window.setInterval> | null = null;
@@ -84,27 +83,14 @@ export class SessionManager {
8483

8584
if (now - this.lastHeartbeat > 5000 && this.isTracking) {
8685
console.warn('[Limitra] Sleep detected. Erasing time gap.');
87-
await this.storage.startSession(this.adapter.id);
8886
this.lastHeartbeat = now;
89-
this.lastSyncTime = now;
9087
return;
9188
}
9289

9390
this.lastHeartbeat = now;
9491

95-
if (this.isTracking && now - this.lastSyncTime >= 10000) {
96-
await this.storage.endSession(this.adapter.id);
97-
await this.storage.startSession(this.adapter.id);
98-
this.lastSyncTime = now;
99-
}
100-
101-
if (document.hidden) return;
102-
103-
const isTimeEnabled = await this.storage.getEnableTime(this.adapter.id);
104-
const timeLimit = await this.storage.getTimeLimit(this.adapter.id);
105-
106-
if (!isTimeEnabled || timeLimit <= 0) {
107-
await this.stopTracking();
92+
if (document.hidden) {
93+
if (this.isTracking) await this.stopTracking();
10894
return;
10995
}
11096

@@ -117,8 +103,15 @@ export class SessionManager {
117103
else if (mode === 'playing_only') shouldTrack = isPlaying;
118104
else if (mode === 'smart') shouldTrack = isPlaying || !isIdle;
119105

120-
if (shouldTrack) await this.startTracking();
121-
else await this.stopTracking();
106+
if (shouldTrack) {
107+
if (!this.isTracking) {
108+
await this.startTracking();
109+
} else {
110+
await this.storage.updateSessionTime(this.adapter.id);
111+
}
112+
} else {
113+
if (this.isTracking) await this.stopTracking();
114+
}
122115
}, 2000);
123116
}
124117

src/core/storage/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,22 @@ import { SecurityStorage } from './security';
66
import { PlatformId, PLATFORMS_CONFIG } from '../../types';
77
import { AnalyticsRepository } from '../interfaces/analytics-repository';
88
import { AnalyticsService } from '../analytics/service';
9+
import { SubscriptionService } from '../subscription/service';
910

1011
export class StorageFacade {
1112
public settings: SettingsStorage;
1213
public stats: StatsStorage;
1314
public session: SessionStorage;
1415
public security: SecurityStorage;
1516
public analyticsService?: AnalyticsService;
17+
public subscriptionService: SubscriptionService;
1618

1719
constructor(public driver: StorageDriver) {
1820
this.settings = new SettingsStorage(this.driver);
1921
this.stats = new StatsStorage(this.driver);
2022
this.session = new SessionStorage(this.driver, this.stats);
2123
this.security = new SecurityStorage(this.driver, this.stats);
24+
this.subscriptionService = new SubscriptionService();
2225
}
2326

2427
/**
@@ -205,7 +208,7 @@ export class StorageFacade {
205208
// Analytics & Dependencies
206209
public setAnalyticsRepository(repo: AnalyticsRepository): void {
207210
this.session.setAnalyticsRepository(repo);
208-
this.analyticsService = new AnalyticsService(repo);
211+
this.analyticsService = new AnalyticsService(repo, this.subscriptionService);
209212
}
210213

211214
// Sessions
@@ -226,6 +229,9 @@ export class StorageFacade {
226229
async getNextResetTime() {
227230
return this.session.getNextResetTime();
228231
}
232+
async updateSessionTime(platform: PlatformId) {
233+
return this.session.updateSessionTime(platform);
234+
}
229235

230236
// Security
231237
async detectBypass(platform: PlatformId) {

src/core/storage/session.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,21 +89,19 @@ export class SessionStorage {
8989
})
9090
.catch(() => {});
9191
}
92-
}, 60000);
93-
92+
}, 5000);
9493
this.activeSessions.get(platform)!.interval = interval;
9594
}
9695
}
9796

9897
async endSession(platform: PlatformId): Promise<number> {
9998
const key = `limitra_${platform}_last_active`;
10099
const last = await this.driver.get<number>(key);
101-
102100
const activeSession = this.activeSessions.get(platform);
101+
103102
if (activeSession) {
104103
if (activeSession.interval) clearInterval(activeSession.interval);
105-
106-
if (this.analyticsRepo && last && last !== 0) {
104+
if (this.analyticsRepo) {
107105
const currentTime = Date.now();
108106
await this.analyticsRepo
109107
.saveRecord({
@@ -123,6 +121,7 @@ export class SessionStorage {
123121
const delta = Date.now() - last;
124122
await this.stats.addTime(platform, delta);
125123
await this.driver.set<number>(key, 0);
124+
126125
return delta;
127126
}
128127

@@ -134,4 +133,16 @@ export class SessionStorage {
134133
const intervalMs = await this.getDurationMs();
135134
return lastReset + intervalMs;
136135
}
136+
137+
async updateSessionTime(platform: PlatformId): Promise<void> {
138+
const key = `limitra_${platform}_last_active`;
139+
const last = await this.driver.get<number>(key);
140+
if (!last || last === 0) return;
141+
142+
const now = Date.now();
143+
const delta = now - last;
144+
145+
await this.stats.addTime(platform, delta);
146+
await this.driver.set<number>(key, now);
147+
}
137148
}

0 commit comments

Comments
 (0)