@reservamos/browser-analytics 1.1.1 → 1.1.2

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.1",
3
+ "version": "1.1.2",
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,11 +101,13 @@ 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
+ geolocation_source: 'browser',
106
107
  };
107
108
 
108
109
  expect(coordinates).toEqual({ lat: 19.4326, long: -99.1332 });
110
+ expect(geolocationService.getGeolocationSource()).toBe('browser');
109
111
  expect(trackCustomEventMock).toHaveBeenCalledWith(
110
112
  'Geolocation Requested',
111
113
  expectedProperties,
@@ -113,8 +115,9 @@ describe('geolocationService', () => {
113
115
  expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
114
116
  'Geolocation Updated At': updatedAt,
115
117
  'Geolocation Status': 'granted',
116
- $latitude: 19.4326,
117
- $longitude: -99.1332,
118
+ latitude: 19.4326,
119
+ longitude: -99.1332,
120
+ geolocation_source: 'browser',
118
121
  });
119
122
 
120
123
  vi.useRealTimers();
@@ -142,6 +145,7 @@ describe('geolocationService', () => {
142
145
  };
143
146
 
144
147
  expect(coordinates).toBeNull();
148
+ expect(geolocationService.getGeolocationSource()).toBeNull();
145
149
  expect(trackCustomEventMock).toHaveBeenCalledWith(
146
150
  'Geolocation Requested',
147
151
  expectedProperties,
@@ -217,8 +221,9 @@ describe('geolocationService', () => {
217
221
  'Geolocation Updated At': updatedAt,
218
222
  'Geolocation Status': 'skipped_cache',
219
223
  'Geolocation Accuracy': 25,
220
- $latitude: 19.4326,
221
- $longitude: -99.1332,
224
+ latitude: 19.4326,
225
+ longitude: -99.1332,
226
+ geolocation_source: 'browser',
222
227
  };
223
228
 
224
229
  expect(coordinates).toEqual({ lat: 19.4326, long: -99.1332 });
@@ -229,33 +234,114 @@ describe('geolocationService', () => {
229
234
  expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
230
235
  'Geolocation Updated At': updatedAt,
231
236
  'Geolocation Status': 'skipped_cache',
232
- $latitude: 19.4326,
233
- $longitude: -99.1332,
237
+ latitude: 19.4326,
238
+ longitude: -99.1332,
239
+ geolocation_source: 'browser',
234
240
  });
235
241
  });
236
242
 
237
- it('ignores malformed geolocation cache entries', async () => {
238
- const permissionTimestamp = Date.now().toString();
239
- const expectedUpdatedAt = new Date(Number(permissionTimestamp)).toISOString();
243
+ it('repairs legacy storage by clearing permission timestamp when coordinates are missing', async () => {
244
+ const updatedAt = '2026-05-07T21:19:00.000Z';
245
+ vi.useFakeTimers();
246
+ vi.setSystemTime(new Date(updatedAt));
240
247
 
241
- localStorage.setItem('geolocationPermissionTimestamp', permissionTimestamp);
248
+ localStorage.setItem('geolocationPermissionTimestamp', Date.now().toString());
242
249
  localStorage.setItem('geolocationCache', '{ invalid json');
243
250
 
244
- vi.stubGlobal('navigator', {
245
- geolocation: {
246
- getCurrentPosition: () => {
247
- throw new Error('getCurrentPosition should not be called');
248
- },
249
- },
251
+ mockGeolocationSuccess({
252
+ coords: { latitude: 19.4326, longitude: -99.1332, accuracy: 15 },
253
+ } as GeolocationPosition);
254
+
255
+ const geolocationService = (await import('./geolocation')).default;
256
+ await geolocationService.init();
257
+
258
+ expect(localStorage.getItem('geolocationPermissionTimestamp')).toBe(
259
+ Date.parse(updatedAt).toString(),
260
+ );
261
+ expect(JSON.parse(localStorage.getItem('geolocationCache') ?? '')).toEqual({
262
+ lat: 19.4326,
263
+ long: -99.1332,
264
+ updatedAt,
265
+ accuracy: 15,
250
266
  });
251
267
 
268
+ vi.useRealTimers();
269
+ });
270
+
271
+ it('requests fresh geolocation when the permission cache is valid but coordinates are missing', async () => {
272
+ const updatedAt = '2026-05-07T21:19:00.000Z';
273
+ vi.useFakeTimers();
274
+ vi.setSystemTime(new Date(updatedAt));
275
+
276
+ localStorage.setItem('geolocationPermissionTimestamp', Date.now().toString());
277
+ localStorage.setItem('geolocationCache', '{ invalid json');
278
+
279
+ mockGeolocationSuccess({
280
+ coords: { latitude: 19.4326, longitude: -99.1332, accuracy: 15 },
281
+ } as GeolocationPosition);
282
+
252
283
  const geolocationService = (await import('./geolocation')).default;
253
284
  const coordinates = await geolocationService.init();
254
285
 
255
- expect(coordinates).toBeNull();
256
- expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
257
- 'Geolocation Updated At': expectedUpdatedAt,
258
- 'Geolocation Status': 'skipped_cache',
259
- });
286
+ expect(coordinates).toEqual({ lat: 19.4326, long: -99.1332 });
287
+ expect(trackCustomEventMock).toHaveBeenCalledWith(
288
+ 'Geolocation Requested',
289
+ {
290
+ 'Geolocation Updated At': updatedAt,
291
+ 'Geolocation Status': 'granted',
292
+ 'Geolocation Accuracy': 15,
293
+ latitude: 19.4326,
294
+ longitude: -99.1332,
295
+ geolocation_source: 'browser',
296
+ },
297
+ );
298
+
299
+ vi.useRealTimers();
300
+ });
301
+
302
+ it('requests fresh geolocation when the permission cache is older than one hour', async () => {
303
+ const updatedAt = '2026-05-07T21:19:00.000Z';
304
+ vi.useFakeTimers();
305
+ vi.setSystemTime(new Date(updatedAt));
306
+
307
+ const expiredPermissionTimestamp = (
308
+ Date.parse(updatedAt) - 60 * 60 * 1000 - 1
309
+ ).toString();
310
+
311
+ localStorage.setItem(
312
+ 'geolocationPermissionTimestamp',
313
+ expiredPermissionTimestamp,
314
+ );
315
+ localStorage.setItem(
316
+ 'geolocationCache',
317
+ JSON.stringify({
318
+ lat: 19.4326,
319
+ long: -99.1332,
320
+ updatedAt: '2026-05-07T20:00:00.000Z',
321
+ accuracy: 25,
322
+ }),
323
+ );
324
+
325
+ mockGeolocationSuccess({
326
+ coords: { latitude: 20.9674, longitude: -89.5926, accuracy: 8 },
327
+ } as GeolocationPosition);
328
+
329
+ const geolocationService = (await import('./geolocation')).default;
330
+ const coordinates = await geolocationService.init();
331
+
332
+ expect(coordinates).toEqual({ lat: 20.9674, long: -89.5926 });
333
+ expect(trackCustomEventMock).toHaveBeenCalledWith(
334
+ 'Geolocation Requested',
335
+ {
336
+ 'Geolocation Updated At': updatedAt,
337
+ 'Geolocation Status': 'granted',
338
+ 'Geolocation Accuracy': 8,
339
+ latitude: 20.9674,
340
+ longitude: -89.5926,
341
+ geolocation_source: 'browser',
342
+ },
343
+ );
344
+
345
+ vi.useRealTimers();
260
346
  });
261
347
  });
@@ -14,11 +14,20 @@ interface GeolocationCache {
14
14
  accuracy?: number;
15
15
  }
16
16
 
17
+ /**
18
+ * Origin of the coordinates attached to geolocation events and profiles.
19
+ * - `browser` — Coordinates from the Geolocation API or a prior browser session cache.
20
+ */
21
+ type GeolocationSource = 'browser';
22
+
17
23
  interface GeolocationTrackingData {
18
24
  status: GeolocationStatus;
19
25
  updatedAt: string;
26
+ lat?: number;
27
+ long?: number;
20
28
  accuracy?: number;
21
29
  errorCode?: number;
30
+ source?: GeolocationSource;
22
31
  }
23
32
 
24
33
  /**
@@ -28,16 +37,17 @@ interface GeolocationTrackingData {
28
37
  * - `granted` — The browser returned fresh coordinates successfully.
29
38
  * - `denied` — The user blocked location access (PERMISSION_DENIED).
30
39
  * - `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.
40
+ * - `skipped_cache` — Permission was granted within the last hour; cached coordinates were reused without a new browser request.
32
41
  */
33
42
  type GeolocationStatus = 'granted' | 'denied' | 'error' | 'skipped_cache';
34
43
 
35
44
  let currentCoordinates: Coordinates | null = null;
45
+ let currentSource: GeolocationSource | null = null;
36
46
 
37
47
  const GEOLOCATION_PERMISSION_KEY = 'geolocationPermissionTimestamp';
38
48
  const GEOLOCATION_CACHE_KEY = 'geolocationCache';
39
49
  const GEOLOCATION_TRACK_EVENT = 'Geolocation Requested';
40
- const SEVEN_DAYS_IN_MS = 7 * 24 * 60 * 60 * 1000;
50
+ const ONE_HOUR_IN_MS = 60 * 60 * 1000;
41
51
  /** Max wait for a high-accuracy position fix before the browser reports TIMEOUT. */
42
52
  const GEOLOCATION_REQUEST_TIMEOUT_MS = 10_000;
43
53
  const GEOLOCATION_OPTIONS: PositionOptions = {
@@ -72,6 +82,18 @@ function safeSetItem(key: string, value: string): void {
72
82
  }
73
83
  }
74
84
 
85
+ /**
86
+ * Removes a value from localStorage, ignoring failures when storage is unavailable.
87
+ * @param {string} key - Storage key to remove.
88
+ */
89
+ function safeRemoveItem(key: string): void {
90
+ try {
91
+ localStorage.removeItem(key);
92
+ } catch {
93
+ // Storage may be blocked (private mode, quota exceeded, etc.)
94
+ }
95
+ }
96
+
75
97
  /**
76
98
  * Converts a Unix timestamp to an ISO 8601 string.
77
99
  * @param {number} timestamp - Unix timestamp in milliseconds.
@@ -150,6 +172,7 @@ function restoreCoordinatesFromStorage(): void {
150
172
 
151
173
  if (cache) {
152
174
  currentCoordinates = { lat: cache.lat, long: cache.long };
175
+ currentSource = 'browser';
153
176
  }
154
177
  }
155
178
 
@@ -163,7 +186,7 @@ function isPermissionTimestampValid(
163
186
  ): boolean {
164
187
  if (!lastPermissionTimestamp) return false;
165
188
  const now = Date.now();
166
- return now - parseInt(lastPermissionTimestamp, 10) < SEVEN_DAYS_IN_MS;
189
+ return now - parseInt(lastPermissionTimestamp, 10) < ONE_HOUR_IN_MS;
167
190
  }
168
191
 
169
192
  /**
@@ -174,6 +197,21 @@ function updatePermissionTimestamp(timestamp: number): void {
174
197
  safeSetItem(GEOLOCATION_PERMISSION_KEY, timestamp.toString());
175
198
  }
176
199
 
200
+ /**
201
+ * Repairs inconsistent localStorage left by older SDK versions that persisted a
202
+ * permission timestamp even when coordinates were never cached (e.g. after deny or error).
203
+ * Runs automatically on init so users do not need to clear storage after upgrading.
204
+ */
205
+ function repairLegacyGeolocationStorage(): void {
206
+ if (!safeGetItem(GEOLOCATION_PERMISSION_KEY)) {
207
+ return;
208
+ }
209
+
210
+ if (!loadCoordinatesFromStorage()) {
211
+ safeRemoveItem(GEOLOCATION_PERMISSION_KEY);
212
+ }
213
+ }
214
+
177
215
  /**
178
216
  * Maps a GeolocationPositionError to a geolocation status.
179
217
  * Permission denial maps to `denied`; timeout and position unavailable map to `error`.
@@ -207,10 +245,17 @@ function buildGeolocationEventProperties(
207
245
  properties['Geolocation Error Code'] = data.errorCode;
208
246
  }
209
247
 
210
- const coordinates = getCoordinates();
211
- if (coordinates) {
212
- properties.$latitude = coordinates.lat;
213
- properties.$longitude = coordinates.long;
248
+ if (
249
+ (data.status === 'granted' || data.status === 'skipped_cache') &&
250
+ data.lat !== undefined &&
251
+ data.long !== undefined
252
+ ) {
253
+ properties.latitude = data.lat;
254
+ properties.longitude = data.long;
255
+ }
256
+
257
+ if (data.source !== undefined) {
258
+ properties.geolocation_source = data.source;
214
259
  }
215
260
 
216
261
  return properties;
@@ -230,10 +275,17 @@ function buildGeolocationProfileProperties(
230
275
  'Geolocation Status': data.status,
231
276
  };
232
277
 
233
- const coordinates = getCoordinates();
234
- if (coordinates) {
235
- properties.$latitude = coordinates.lat;
236
- properties.$longitude = coordinates.long;
278
+ if (
279
+ (data.status === 'granted' || data.status === 'skipped_cache') &&
280
+ data.lat !== undefined &&
281
+ data.long !== undefined
282
+ ) {
283
+ properties.latitude = data.lat;
284
+ properties.longitude = data.long;
285
+ }
286
+
287
+ if (data.source !== undefined) {
288
+ properties.geolocation_source = data.source;
237
289
  }
238
290
 
239
291
  return properties;
@@ -260,6 +312,7 @@ function syncGeolocationToMixpanel(data: GeolocationTrackingData): void {
260
312
  * @returns {Promise<Coordinates | null>} Cached or fresh coordinates, or null on failure.
261
313
  */
262
314
  function init(): Promise<Coordinates | null> {
315
+ repairLegacyGeolocationStorage();
263
316
  restoreCoordinatesFromStorage();
264
317
 
265
318
  return new Promise((resolve) => {
@@ -267,18 +320,19 @@ function init(): Promise<Coordinates | null> {
267
320
 
268
321
  if (isPermissionTimestampValid(lastPermissionTimestamp)) {
269
322
  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;
323
+
324
+ if (cache) {
325
+ syncGeolocationToMixpanel({
326
+ status: 'skipped_cache',
327
+ updatedAt: cache.updatedAt,
328
+ lat: cache.lat,
329
+ long: cache.long,
330
+ accuracy: cache.accuracy,
331
+ source: 'browser',
332
+ });
333
+ resolve(currentCoordinates);
334
+ return;
335
+ }
282
336
  }
283
337
 
284
338
  const now = Date.now();
@@ -294,19 +348,22 @@ function init(): Promise<Coordinates | null> {
294
348
  (geolocation) => {
295
349
  const { latitude: lat, longitude: long, accuracy } = geolocation.coords;
296
350
  currentCoordinates = { lat, long };
351
+ currentSource = 'browser';
297
352
  saveCoordinatesToStorage(currentCoordinates, updatedAt, accuracy);
298
353
  updatePermissionTimestamp(now);
299
354
 
300
355
  syncGeolocationToMixpanel({
301
356
  status: 'granted',
302
357
  updatedAt,
358
+ lat,
359
+ long,
303
360
  accuracy,
361
+ source: 'browser',
304
362
  });
305
363
  resolve(currentCoordinates);
306
364
  },
307
365
  (error) => {
308
366
  const status = getGeolocationStatus(error);
309
- updatePermissionTimestamp(now);
310
367
 
311
368
  syncGeolocationToMixpanel({
312
369
  status,
@@ -328,6 +385,14 @@ function getCoordinates(): Coordinates | null {
328
385
  return currentCoordinates;
329
386
  }
330
387
 
388
+ /**
389
+ * Returns the source of the currently cached coordinates, or null when unavailable.
390
+ * @returns {GeolocationSource | null} The geolocation source or null if no coordinates are cached.
391
+ */
392
+ function getGeolocationSource(): GeolocationSource | null {
393
+ return currentSource;
394
+ }
395
+
331
396
  /**
332
397
  * Geolocation service for tracking user location with caching support.
333
398
  * Handles permission requests, coordinate retrieval, and analytics sync to Mixpanel.
@@ -335,6 +400,7 @@ function getCoordinates(): Coordinates | null {
335
400
  const geolocationService = {
336
401
  init,
337
402
  getCoordinates,
403
+ getGeolocationSource,
338
404
  };
339
405
 
340
406
  export default geolocationService;
package/src/track.ts CHANGED
@@ -125,9 +125,13 @@ export async function trackEvent(
125
125
  const fingerprint = await fingerprintService.getFingerprint(cacheOnly);
126
126
  const defaultCoordinates: Record<string, string | number> = {};
127
127
  const coordinates = geolocationService.getCoordinates();
128
+ const geolocationSource = geolocationService.getGeolocationSource();
128
129
  if (coordinates) {
129
- defaultCoordinates.$latitude = coordinates.lat;
130
- defaultCoordinates.$longitude = coordinates.long;
130
+ defaultCoordinates.latitude = coordinates.lat;
131
+ defaultCoordinates.longitude = coordinates.long;
132
+ }
133
+ if (geolocationSource) {
134
+ defaultCoordinates.geolocation_source = geolocationSource;
131
135
  }
132
136
  const defaultProperties = {
133
137
  'User Fingerprint': fingerprint,