Skip to content

Commit 207082c

Browse files
committed
feat(analytics): implement session stitching and multi-tab synchronization
- Added a 15-minute grace period to merge interrupted sessions accurately. - Introduced accumulated session duration tracking for behavioral continuity across interruptions. - Moved paused session state to centralized storage to fix multi-tab desynchronization. - Implemented double-start protection to prevent duplicate timers and memory leaks. - Added a compound `platform_time` index for faster analytics queries. - Ensured session totals are calculated using unique session IDs to prevent double counting. - Disabled destructive analytics retention cleanup to preserve long-term behavioral history. - Added analytics reporting, trend visualization, and premium feature placeholders. - Added unit tests covering session continuity, grace period expiration, and edge-case scenarios.
1 parent 4c67bf5 commit 207082c

15 files changed

Lines changed: 452 additions & 51 deletions

File tree

src/adapters/browser/indexeddb-analytics.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ export class IndexedDbAnalyticsRepository implements AnalyticsRepository {
99
private async getDb(): Promise<IDBDatabase> {
1010
return new Promise((resolve, reject) => {
1111
const request = indexedDB.open(this.dbName, this.version);
12-
1312
request.onupgradeneeded = (event) => {
1413
const db = (event.target as IDBOpenDBRequest).result;
14+
1515
if (!db.objectStoreNames.contains(this.storeName)) {
1616
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
1717
store.createIndex('platformId', 'platformId', { unique: false });
1818
store.createIndex('startTime', 'startTime', { unique: false });
19+
store.createIndex('platform_time', ['platformId', 'startTime'], { unique: false });
1920
}
2021
};
2122

src/core/analytics/aggregator.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export class AnalyticsAggregator {
4040
* the definition of a session changes in the future
4141
*/
4242
public static calculateSessionCount(records: AnalyticsRecord[]): number {
43-
return records.length;
43+
const uniqueSessionIds = new Set(records.map((record) => record.id));
44+
return uniqueSessionIds.size;
4445
}
4546
}

src/core/analytics/reports/dashboard-report.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { AnalyticsRepository } from '../../interfaces/analytics-repository';
22
import { AnalyticsAggregator } from '../aggregator';
33
import { AnalyticsFormatter, TimeTranslations } from '../formatter';
44

5+
export interface TrendDayResult {
6+
dayName: string;
7+
totalMs: number;
8+
dateLabel: string;
9+
}
10+
511
export interface DashboardReportResult {
612
raw: {
713
todayMs: number;
@@ -13,15 +19,19 @@ export interface DashboardReportResult {
1319
trendText: string;
1420
isImproving: boolean;
1521
};
22+
weeklyTrend?: TrendDayResult[];
1623
}
1724

1825
export class DashboardReportGenerator {
1926
constructor(private repo: AnalyticsRepository) {}
2027

21-
private async getUsageForDay(platformId: string, startOfDayMs: number) {
28+
private async getUsageForDay(platformId: string | undefined, startOfDayMs: number) {
2229
const endOfDayMs = startOfDayMs + 24 * 60 * 60 * 1000 - 1;
30+
31+
const queryId = platformId === 'all' ? undefined : platformId;
32+
2333
const records = await this.repo.queryRecords({
24-
platformId: platformId,
34+
platformId: queryId,
2535
fromTime: startOfDayMs,
2636
toTime: endOfDayMs,
2737
});
@@ -35,6 +45,7 @@ export class DashboardReportGenerator {
3545
public async generate(
3646
platformId: string,
3747
translations: TimeTranslations,
48+
daysCount: number = 7,
3849
): Promise<DashboardReportResult> {
3950
const now = new Date();
4051
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
@@ -48,6 +59,19 @@ export class DashboardReportGenerator {
4859
todayData.duration,
4960
);
5061

62+
const weeklyTrend: TrendDayResult[] = [];
63+
for (let i = daysCount - 1; i >= 0; i--) {
64+
const dayStart = todayStart - i * 24 * 60 * 60 * 1000;
65+
const dayData = await this.getUsageForDay(platformId, dayStart);
66+
const dateObj = new Date(dayStart);
67+
68+
weeklyTrend.push({
69+
dayName: dateObj.toLocaleDateString('en-US', { weekday: 'short' }),
70+
totalMs: dayData.duration,
71+
dateLabel: dateObj.toLocaleDateString(),
72+
});
73+
}
74+
5175
return {
5276
raw: {
5377
todayMs: todayData.duration,
@@ -59,6 +83,7 @@ export class DashboardReportGenerator {
5983
trendText: AnalyticsFormatter.formatTrend(trendPercent),
6084
isImproving: trendPercent <= 0,
6185
},
86+
weeklyTrend,
6287
};
6388
}
6489
}

src/core/analytics/service.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
import { AnalyticsAggregator } from './aggregator';
22
import { AnalyticsRepository } from '../interfaces/analytics-repository';
3-
import { SubscriptionService } from '../subscription/service';
4-
import { LimitType } from '../subscription/limits';
53
import { DashboardReportGenerator } from './reports/dashboard-report';
64
import { TimeTranslations } from './formatter';
75

86
export class AnalyticsService {
97
private dashboardReport: DashboardReportGenerator;
108

11-
constructor(
12-
private repo: AnalyticsRepository,
13-
private subscriptionService: SubscriptionService,
14-
) {
9+
constructor(private repo: AnalyticsRepository) {
1510
this.dashboardReport = new DashboardReportGenerator(this.repo);
1611
}
1712

@@ -89,18 +84,21 @@ export class AnalyticsService {
8984
}
9085

9186
/**
92-
* Removes old analytics data to save storage space (Garbage Collection).
93-
* By default, records older than 7 days will be deleted.
87+
* Garbage collection hook reserved for future storage maintenance strategies.
88+
*
89+
* Analytics retention is currently set to lifetime because session-based
90+
* records are lightweight and valuable for long-term behavioral analysis.
91+
*
92+
* In future releases, this routine may be reused for:
93+
* - tier-based retention policies
94+
* - archive/compression strategies
95+
* - temporary cache cleanup
96+
* - cloud synchronization maintenance
97+
* - AI memory optimization
9498
*/
9599
public async performGarbageCollection(): Promise<void> {
96-
const retentionDays = this.subscriptionService.getLimit(LimitType.ANALYTICS_RETENTION_DAYS);
97-
98-
const cutoffTime = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
99-
100-
await this.repo.clearRecordsOlderThan(cutoffTime);
101-
102100
console.info(
103-
`[Limitra Analytics] Garbage collection completed. Cleared records older than ${retentionDays} days.`,
101+
'[Limitra Analytics] Garbage collection skipped. Lifetime retention mode is active.',
104102
);
105103
}
106104

src/core/analytics/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface AnalyticsRecord {
88
startTime: number;
99
endTime: number;
1010
durationMs: number;
11+
type?: 'SESSION';
1112
}
1213

1314
/**

src/core/storage/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ export class StorageFacade {
208208
// Analytics & Dependencies
209209
public setAnalyticsRepository(repo: AnalyticsRepository): void {
210210
this.session.setAnalyticsRepository(repo);
211-
this.analyticsService = new AnalyticsService(repo, this.subscriptionService);
211+
this.analyticsService = new AnalyticsService(repo);
212212
}
213213

214214
// Sessions

src/core/storage/session.ts

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,17 @@ const NEXT_RESET_KEY = 'limitra_next_reset';
1212
export class SessionStorage {
1313
private activeSessions: Map<
1414
PlatformId,
15-
{ id: string; startTime: number; interval?: ReturnType<typeof setInterval> }
15+
{
16+
id: string;
17+
absoluteStartTime: number;
18+
segmentStartTime: number;
19+
accumulatedMs: number;
20+
interval?: ReturnType<typeof setInterval>;
21+
}
1622
> = new Map();
23+
24+
private readonly GRACE_PERIOD_MS = 15 * 60 * 1000;
25+
1726
private analyticsRepo?: AnalyticsRepository;
1827

1928
constructor(
@@ -59,37 +68,76 @@ export class SessionStorage {
5968
}
6069

6170
async startSession(platform: PlatformId): Promise<void> {
71+
if (this.activeSessions.has(platform)) {
72+
return;
73+
}
74+
6275
const now = Date.now();
6376
await this.driver.set<number>(`limitra_${platform}_last_active`, now);
6477

6578
if (this.analyticsRepo) {
66-
const sessionId = generateUUID();
67-
this.activeSessions.set(platform, { id: sessionId, startTime: now });
79+
const pausedKey = `limitra_${platform}_paused_session`;
80+
const paused = await this.driver.get<{
81+
id: string;
82+
startTime: number;
83+
endTime: number;
84+
durationMs: number;
85+
}>(pausedKey);
86+
87+
let sessionId: string;
88+
let absoluteStartTime: number;
89+
let accumulatedMs: number;
90+
91+
if (paused && now - paused.endTime <= this.GRACE_PERIOD_MS) {
92+
sessionId = paused.id;
93+
absoluteStartTime = paused.startTime;
94+
accumulatedMs = paused.durationMs;
95+
console.info(`[Limitra Analytics] Resuming session for ${platform} across tabs`);
96+
await this.driver.set(pausedKey, null);
97+
} else {
98+
sessionId = generateUUID();
99+
absoluteStartTime = now;
100+
accumulatedMs = 0;
101+
await this.driver.set(pausedKey, null);
102+
}
103+
104+
this.activeSessions.set(platform, {
105+
id: sessionId,
106+
absoluteStartTime,
107+
segmentStartTime: now,
108+
accumulatedMs,
109+
});
68110

69111
const record: AnalyticsRecord = {
70112
id: sessionId,
71113
platformId: platform,
72-
startTime: now,
114+
startTime: absoluteStartTime,
73115
endTime: now,
74-
durationMs: 0,
116+
durationMs: accumulatedMs,
117+
type: 'SESSION',
75118
};
76119
await this.analyticsRepo.saveRecord(record).catch(() => {});
77120

78121
const interval = setInterval(async () => {
79122
const session = this.activeSessions.get(platform);
80123
if (session && this.analyticsRepo) {
81124
const currentTime = Date.now();
125+
const currentSegment = currentTime - session.segmentStartTime;
126+
const totalDuration = session.accumulatedMs + currentSegment;
127+
82128
await this.analyticsRepo
83129
.saveRecord({
84130
id: session.id,
85131
platformId: platform,
86-
startTime: session.startTime,
132+
startTime: session.absoluteStartTime,
87133
endTime: currentTime,
88-
durationMs: currentTime - session.startTime,
134+
durationMs: totalDuration,
135+
type: 'SESSION',
89136
})
90137
.catch(() => {});
91138
}
92-
}, 5000);
139+
}, 30000);
140+
93141
this.activeSessions.get(platform)!.interval = interval;
94142
}
95143
}
@@ -101,17 +149,29 @@ export class SessionStorage {
101149

102150
if (activeSession) {
103151
if (activeSession.interval) clearInterval(activeSession.interval);
152+
104153
if (this.analyticsRepo) {
105154
const currentTime = Date.now();
155+
const currentSegment = currentTime - activeSession.segmentStartTime;
156+
const totalDuration = activeSession.accumulatedMs + currentSegment;
157+
106158
await this.analyticsRepo
107159
.saveRecord({
108160
id: activeSession.id,
109161
platformId: platform,
110-
startTime: activeSession.startTime,
162+
startTime: activeSession.absoluteStartTime,
111163
endTime: currentTime,
112-
durationMs: currentTime - activeSession.startTime,
164+
durationMs: totalDuration,
165+
type: 'SESSION',
113166
})
114167
.catch(() => {});
168+
169+
await this.driver.set(`limitra_${platform}_paused_session`, {
170+
id: activeSession.id,
171+
startTime: activeSession.absoluteStartTime,
172+
endTime: currentTime,
173+
durationMs: totalDuration,
174+
});
115175
}
116176
this.activeSessions.delete(platform);
117177
}

src/i18n/locales/ar.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ const ar: LocaleStrings = {
8484
statTrend: 'مقارنة بالأمس',
8585
hoursShort: 'س',
8686
minutesShort: 'د',
87+
optAllPlatforms: 'كل المنصات',
88+
usageTrends: 'مسار الاستخدام',
89+
last7Days: 'آخر 7 أيام',
90+
last30Days: 'آخر 30 يوم (Pro)',
91+
last1Year: 'آخر سنة (Pro)',
92+
premiumBadge: 'احترافي',
93+
comingSoonTitle: 'ميزة مدفوعة',
94+
comingSoonDesc:
95+
'التحليلات الزمنية المتقدمة (تقارير 30 يوم والتقارير السنوية) ستكون متاحة قريباً في النسخة المدفوعة. خليك قريب!',
8796
},
8897
tones: {
8998
random: 'عشوائي',

src/i18n/locales/en.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,15 @@ const en: LocaleStrings = {
8686
statTrend: 'Vs Yesterday',
8787
hoursShort: 'h',
8888
minutesShort: 'm',
89+
optAllPlatforms: 'All Platforms',
90+
usageTrends: 'Usage Trends',
91+
last7Days: 'Last 7 Days',
92+
last30Days: 'Last 30 Days (Pro)',
93+
last1Year: 'Last 1 Year (Pro)',
94+
premiumBadge: 'Premium',
95+
comingSoonTitle: 'Premium Feature',
96+
comingSoonDesc:
97+
'Advanced time-series analytics (30 days and yearly reports) will be available soon in the Pro tier. Stay tuned!',
8998
},
9099
tones: {
91100
random: 'Random',

src/i18n/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ export interface LocaleStrings {
8383
statTrend: string;
8484
hoursShort: string;
8585
minutesShort: string;
86+
optAllPlatforms: string;
87+
usageTrends: string;
88+
last7Days: string;
89+
last30Days: string;
90+
last1Year: string;
91+
premiumBadge: string;
92+
comingSoonTitle: string;
93+
comingSoonDesc: string;
8694
};
8795
tones: {
8896
random: string;

0 commit comments

Comments
 (0)