@reservamos/browser-analytics 1.1.0 → 1.1.2-alpha.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reservamos/browser-analytics",
3
- "version": "1.1.0",
3
+ "version": "1.1.2-alpha.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/reservamos/reservamos-browser-analytics.git"
package/src/init.ts CHANGED
@@ -49,7 +49,7 @@ async function init(config: InitConfig) {
49
49
  console.error('Error initializing identification service:', error);
50
50
  }
51
51
 
52
- geolocationService.init();
52
+ await geolocationService.init();
53
53
 
54
54
  configService.setEnvironment(isSandbox ? 'sandbox' : 'prod');
55
55
  coreApi.setConfig(configService.getCoreAPIConfig());
@@ -101,8 +101,8 @@ describe('geolocationService', () => {
101
101
  'Geolocation Updated At': updatedAt,
102
102
  'Geolocation Status': 'granted',
103
103
  'Geolocation Accuracy': 12,
104
- $latitude: 19.4326,
105
- $longitude: -99.1332,
104
+ latitude: 19.4326,
105
+ longitude: -99.1332,
106
106
  };
107
107
 
108
108
  expect(coordinates).toEqual({ lat: 19.4326, long: -99.1332 });
@@ -113,8 +113,8 @@ describe('geolocationService', () => {
113
113
  expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
114
114
  'Geolocation Updated At': updatedAt,
115
115
  'Geolocation Status': 'granted',
116
- $latitude: 19.4326,
117
- $longitude: -99.1332,
116
+ latitude: 19.4326,
117
+ longitude: -99.1332,
118
118
  });
119
119
 
120
120
  vi.useRealTimers();
@@ -217,8 +217,8 @@ describe('geolocationService', () => {
217
217
  'Geolocation Updated At': updatedAt,
218
218
  'Geolocation Status': 'skipped_cache',
219
219
  'Geolocation Accuracy': 25,
220
- $latitude: 19.4326,
221
- $longitude: -99.1332,
220
+ latitude: 19.4326,
221
+ longitude: -99.1332,
222
222
  };
223
223
 
224
224
  expect(coordinates).toEqual({ lat: 19.4326, long: -99.1332 });
@@ -229,33 +229,111 @@ describe('geolocationService', () => {
229
229
  expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
230
230
  'Geolocation Updated At': updatedAt,
231
231
  'Geolocation Status': 'skipped_cache',
232
- $latitude: 19.4326,
233
- $longitude: -99.1332,
232
+ latitude: 19.4326,
233
+ longitude: -99.1332,
234
234
  });
235
235
  });
236
236
 
237
- it('ignores malformed geolocation cache entries', async () => {
238
- const permissionTimestamp = Date.now().toString();
239
- const expectedUpdatedAt = new Date(Number(permissionTimestamp)).toISOString();
237
+ it('repairs legacy storage by clearing permission timestamp when coordinates are missing', async () => {
238
+ const updatedAt = '2026-05-07T21:19:00.000Z';
239
+ vi.useFakeTimers();
240
+ vi.setSystemTime(new Date(updatedAt));
240
241
 
241
- localStorage.setItem('geolocationPermissionTimestamp', permissionTimestamp);
242
+ localStorage.setItem('geolocationPermissionTimestamp', Date.now().toString());
242
243
  localStorage.setItem('geolocationCache', '{ invalid json');
243
244
 
244
- vi.stubGlobal('navigator', {
245
- geolocation: {
246
- getCurrentPosition: () => {
247
- throw new Error('getCurrentPosition should not be called');
248
- },
249
- },
245
+ mockGeolocationSuccess({
246
+ coords: { latitude: 19.4326, longitude: -99.1332, accuracy: 15 },
247
+ } as GeolocationPosition);
248
+
249
+ const geolocationService = (await import('./geolocation')).default;
250
+ await geolocationService.init();
251
+
252
+ expect(localStorage.getItem('geolocationPermissionTimestamp')).toBe(
253
+ Date.parse(updatedAt).toString(),
254
+ );
255
+ expect(JSON.parse(localStorage.getItem('geolocationCache') ?? '')).toEqual({
256
+ lat: 19.4326,
257
+ long: -99.1332,
258
+ updatedAt,
259
+ accuracy: 15,
250
260
  });
251
261
 
262
+ vi.useRealTimers();
263
+ });
264
+
265
+ it('requests fresh geolocation when the permission cache is valid but coordinates are missing', async () => {
266
+ const updatedAt = '2026-05-07T21:19:00.000Z';
267
+ vi.useFakeTimers();
268
+ vi.setSystemTime(new Date(updatedAt));
269
+
270
+ localStorage.setItem('geolocationPermissionTimestamp', Date.now().toString());
271
+ localStorage.setItem('geolocationCache', '{ invalid json');
272
+
273
+ mockGeolocationSuccess({
274
+ coords: { latitude: 19.4326, longitude: -99.1332, accuracy: 15 },
275
+ } as GeolocationPosition);
276
+
252
277
  const geolocationService = (await import('./geolocation')).default;
253
278
  const coordinates = await geolocationService.init();
254
279
 
255
- expect(coordinates).toBeNull();
256
- expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
257
- 'Geolocation Updated At': expectedUpdatedAt,
258
- 'Geolocation Status': 'skipped_cache',
259
- });
280
+ expect(coordinates).toEqual({ lat: 19.4326, long: -99.1332 });
281
+ expect(trackCustomEventMock).toHaveBeenCalledWith(
282
+ 'Geolocation Requested',
283
+ {
284
+ 'Geolocation Updated At': updatedAt,
285
+ 'Geolocation Status': 'granted',
286
+ 'Geolocation Accuracy': 15,
287
+ latitude: 19.4326,
288
+ longitude: -99.1332,
289
+ },
290
+ );
291
+
292
+ vi.useRealTimers();
293
+ });
294
+
295
+ it('requests fresh geolocation when the permission cache is older than one hour', async () => {
296
+ const updatedAt = '2026-05-07T21:19:00.000Z';
297
+ vi.useFakeTimers();
298
+ vi.setSystemTime(new Date(updatedAt));
299
+
300
+ const expiredPermissionTimestamp = (
301
+ Date.parse(updatedAt) - 60 * 60 * 1000 - 1
302
+ ).toString();
303
+
304
+ localStorage.setItem(
305
+ 'geolocationPermissionTimestamp',
306
+ expiredPermissionTimestamp,
307
+ );
308
+ localStorage.setItem(
309
+ 'geolocationCache',
310
+ JSON.stringify({
311
+ lat: 19.4326,
312
+ long: -99.1332,
313
+ updatedAt: '2026-05-07T20:00:00.000Z',
314
+ accuracy: 25,
315
+ }),
316
+ );
317
+
318
+ mockGeolocationSuccess({
319
+ coords: { latitude: 20.9674, longitude: -89.5926, accuracy: 8 },
320
+ } as GeolocationPosition);
321
+
322
+ const geolocationService = (await import('./geolocation')).default;
323
+ const coordinates = await geolocationService.init();
324
+
325
+ expect(coordinates).toEqual({ lat: 20.9674, long: -89.5926 });
326
+ expect(trackCustomEventMock).toHaveBeenCalledWith(
327
+ 'Geolocation Requested',
328
+ {
329
+ 'Geolocation Updated At': updatedAt,
330
+ 'Geolocation Status': 'granted',
331
+ 'Geolocation Accuracy': 8,
332
+ latitude: 20.9674,
333
+ longitude: -89.5926,
334
+ },
335
+ );
336
+
337
+ vi.useRealTimers();
260
338
  });
261
339
  });
@@ -17,6 +17,8 @@ interface GeolocationCache {
17
17
  interface GeolocationTrackingData {
18
18
  status: GeolocationStatus;
19
19
  updatedAt: string;
20
+ lat?: number;
21
+ long?: number;
20
22
  accuracy?: number;
21
23
  errorCode?: number;
22
24
  }
@@ -28,7 +30,7 @@ interface GeolocationTrackingData {
28
30
  * - `granted` — The browser returned fresh coordinates successfully.
29
31
  * - `denied` — The user blocked location access (PERMISSION_DENIED).
30
32
  * - `error` — The request failed for another reason (timeout, position unavailable, or geolocation unsupported).
31
- * - `skipped_cache` — Permission was granted within the last 7 days; cached coordinates were reused without a new browser request.
33
+ * - `skipped_cache` — Permission was granted within the last hour; cached coordinates were reused without a new browser request.
32
34
  */
33
35
  type GeolocationStatus = 'granted' | 'denied' | 'error' | 'skipped_cache';
34
36
 
@@ -37,7 +39,7 @@ let currentCoordinates: Coordinates | null = null;
37
39
  const GEOLOCATION_PERMISSION_KEY = 'geolocationPermissionTimestamp';
38
40
  const GEOLOCATION_CACHE_KEY = 'geolocationCache';
39
41
  const GEOLOCATION_TRACK_EVENT = 'Geolocation Requested';
40
- const SEVEN_DAYS_IN_MS = 7 * 24 * 60 * 60 * 1000;
42
+ const ONE_HOUR_IN_MS = 60 * 60 * 1000;
41
43
  /** Max wait for a high-accuracy position fix before the browser reports TIMEOUT. */
42
44
  const GEOLOCATION_REQUEST_TIMEOUT_MS = 10_000;
43
45
  const GEOLOCATION_OPTIONS: PositionOptions = {
@@ -72,6 +74,18 @@ function safeSetItem(key: string, value: string): void {
72
74
  }
73
75
  }
74
76
 
77
+ /**
78
+ * Removes a value from localStorage, ignoring failures when storage is unavailable.
79
+ * @param {string} key - Storage key to remove.
80
+ */
81
+ function safeRemoveItem(key: string): void {
82
+ try {
83
+ localStorage.removeItem(key);
84
+ } catch {
85
+ // Storage may be blocked (private mode, quota exceeded, etc.)
86
+ }
87
+ }
88
+
75
89
  /**
76
90
  * Converts a Unix timestamp to an ISO 8601 string.
77
91
  * @param {number} timestamp - Unix timestamp in milliseconds.
@@ -163,7 +177,7 @@ function isPermissionTimestampValid(
163
177
  ): boolean {
164
178
  if (!lastPermissionTimestamp) return false;
165
179
  const now = Date.now();
166
- return now - parseInt(lastPermissionTimestamp, 10) < SEVEN_DAYS_IN_MS;
180
+ return now - parseInt(lastPermissionTimestamp, 10) < ONE_HOUR_IN_MS;
167
181
  }
168
182
 
169
183
  /**
@@ -174,6 +188,21 @@ function updatePermissionTimestamp(timestamp: number): void {
174
188
  safeSetItem(GEOLOCATION_PERMISSION_KEY, timestamp.toString());
175
189
  }
176
190
 
191
+ /**
192
+ * Repairs inconsistent localStorage left by older SDK versions that persisted a
193
+ * permission timestamp even when coordinates were never cached (e.g. after deny or error).
194
+ * Runs automatically on init so users do not need to clear storage after upgrading.
195
+ */
196
+ function repairLegacyGeolocationStorage(): void {
197
+ if (!safeGetItem(GEOLOCATION_PERMISSION_KEY)) {
198
+ return;
199
+ }
200
+
201
+ if (!loadCoordinatesFromStorage()) {
202
+ safeRemoveItem(GEOLOCATION_PERMISSION_KEY);
203
+ }
204
+ }
205
+
177
206
  /**
178
207
  * Maps a GeolocationPositionError to a geolocation status.
179
208
  * Permission denial maps to `denied`; timeout and position unavailable map to `error`.
@@ -207,10 +236,13 @@ function buildGeolocationEventProperties(
207
236
  properties['Geolocation Error Code'] = data.errorCode;
208
237
  }
209
238
 
210
- const coordinates = getCoordinates();
211
- if (coordinates) {
212
- properties.$latitude = coordinates.lat;
213
- properties.$longitude = coordinates.long;
239
+ if (
240
+ (data.status === 'granted' || data.status === 'skipped_cache') &&
241
+ data.lat !== undefined &&
242
+ data.long !== undefined
243
+ ) {
244
+ properties.latitude = data.lat;
245
+ properties.longitude = data.long;
214
246
  }
215
247
 
216
248
  return properties;
@@ -230,10 +262,13 @@ function buildGeolocationProfileProperties(
230
262
  'Geolocation Status': data.status,
231
263
  };
232
264
 
233
- const coordinates = getCoordinates();
234
- if (coordinates) {
235
- properties.$latitude = coordinates.lat;
236
- properties.$longitude = coordinates.long;
265
+ if (
266
+ (data.status === 'granted' || data.status === 'skipped_cache') &&
267
+ data.lat !== undefined &&
268
+ data.long !== undefined
269
+ ) {
270
+ properties.latitude = data.lat;
271
+ properties.longitude = data.long;
237
272
  }
238
273
 
239
274
  return properties;
@@ -260,6 +295,7 @@ function syncGeolocationToMixpanel(data: GeolocationTrackingData): void {
260
295
  * @returns {Promise<Coordinates | null>} Cached or fresh coordinates, or null on failure.
261
296
  */
262
297
  function init(): Promise<Coordinates | null> {
298
+ repairLegacyGeolocationStorage();
263
299
  restoreCoordinatesFromStorage();
264
300
 
265
301
  return new Promise((resolve) => {
@@ -267,18 +303,18 @@ function init(): Promise<Coordinates | null> {
267
303
 
268
304
  if (isPermissionTimestampValid(lastPermissionTimestamp)) {
269
305
  const cache = loadCoordinatesFromStorage();
270
- const updatedAt = cache?.updatedAt
271
- ?? (lastPermissionTimestamp
272
- ? toISO(parseInt(lastPermissionTimestamp, 10))
273
- : new Date().toISOString());
274
-
275
- syncGeolocationToMixpanel({
276
- status: 'skipped_cache',
277
- updatedAt,
278
- accuracy: cache?.accuracy,
279
- });
280
- resolve(currentCoordinates);
281
- return;
306
+
307
+ if (cache) {
308
+ syncGeolocationToMixpanel({
309
+ status: 'skipped_cache',
310
+ updatedAt: cache.updatedAt,
311
+ lat: cache.lat,
312
+ long: cache.long,
313
+ accuracy: cache.accuracy,
314
+ });
315
+ resolve(currentCoordinates);
316
+ return;
317
+ }
282
318
  }
283
319
 
284
320
  const now = Date.now();
@@ -300,13 +336,14 @@ function init(): Promise<Coordinates | null> {
300
336
  syncGeolocationToMixpanel({
301
337
  status: 'granted',
302
338
  updatedAt,
339
+ lat,
340
+ long,
303
341
  accuracy,
304
342
  });
305
343
  resolve(currentCoordinates);
306
344
  },
307
345
  (error) => {
308
346
  const status = getGeolocationStatus(error);
309
- updatePermissionTimestamp(now);
310
347
 
311
348
  syncGeolocationToMixpanel({
312
349
  status,
@@ -9,6 +9,13 @@ declare global {
9
9
 
10
10
  const MIXPANEL_DISTINCT_ID_CACHE_KEY = 'mp_distinct_id';
11
11
 
12
+ /**
13
+ * Super property sent on every Mixpanel event. Fixed SDK marker; not configurable via the public API.
14
+ */
15
+ const EVENT_SOURCE_SUPER_PROPERTY = {
16
+ 'Event Source': 'sdk',
17
+ } as const;
18
+
12
19
  /**
13
20
  * Initializes Mixpanel with the provided token and debug flag.
14
21
  * @param {string} mixpanelToken - The Mixpanel token used for authenticating API requests.
@@ -26,6 +33,7 @@ function init(
26
33
  debug,
27
34
  loaded: () => {
28
35
  window.mixpanel = mixpanel;
36
+ mixpanel.register({ ...EVENT_SOURCE_SUPER_PROPERTY });
29
37
  resolve();
30
38
  },
31
39
  ...(proxyUrl && { api_host: proxyUrl }),
package/src/track.ts CHANGED
@@ -126,8 +126,8 @@ export async function trackEvent(
126
126
  const defaultCoordinates: Record<string, string | number> = {};
127
127
  const coordinates = geolocationService.getCoordinates();
128
128
  if (coordinates) {
129
- defaultCoordinates.$latitude = coordinates.lat;
130
- defaultCoordinates.$longitude = coordinates.long;
129
+ defaultCoordinates.latitude = coordinates.lat;
130
+ defaultCoordinates.longitude = coordinates.long;
131
131
  }
132
132
  const defaultProperties = {
133
133
  'User Fingerprint': fingerprint,