From 599e89c42cb212b30d6bb477016b91665ae26423 Mon Sep 17 00:00:00 2001 From: Bill Chirico Date: Mon, 29 Dec 2025 17:56:47 -0500 Subject: [PATCH 1/3] feat(analytics): migrate from Firebase Analytics to Amplitude (#229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(analytics): add Amplitude migration implementation plan * chore(deps): replace Firebase Analytics with Amplitude SDK - Remove @react-native-firebase/analytics - Remove @react-native-firebase/app - Remove firebase - Add @amplitude/analytics-react-native * feat(analytics): update types for Amplitude with Title Case events - Change event values to Title Case (Amplitude convention) - Add 17 new events for comprehensive tracking (37 total) - Add new user properties (onboarding_completed, task_streak_current, etc.) - Simplify AnalyticsConfig to single apiKey - Add StepsCompletedBucket type - Support string arrays in EventParamValue * refactor(analytics): simplify utils for Amplitude - Remove Platform import (no longer needed) - Update shouldInitializeAnalytics to check AMPLITUDE_API_KEY - Add calculateStepsCompletedBucket helper - Update tests for new behavior * feat(analytics): implement Amplitude SDK for native platforms - Replace Firebase with Amplitude SDK - Add amplitude mock to jest.setup.js - Update native platform implementation - Use Identify class for user properties * fix(analytics): add error handling and use constant for screen view - Add try-catch to setUserPropertiesPlatform() for robustness - Use AnalyticsEvents.SCREEN_VIEWED instead of hardcoded string - Add JSDoc param documentation to trackScreenViewPlatform() * feat(analytics): implement Amplitude SDK for web platform - Replace Firebase with Amplitude browser SDK in platform.web.ts - Add @amplitude/analytics-browser mock to jest.setup.js - Update web platform tests for Amplitude - Fix index.ts to use Amplitude configuration - Install @amplitude/analytics-browser package * refactor(analytics): update main module for Amplitude - Export calculateStepsCompletedBucket utility - Update tests for new export * chore(config): remove Firebase config, add Amplitude - Remove googleServicesFile from iOS/Android config - Remove Firebase plugins from app.config.ts - Replace Firebase env vars with AMPLITUDE_API_KEY in .env.example - Clean up Firebase documentation from config * chore(cleanup): remove Firebase plugin files - Delete withFirebaseConfig.js plugin - Delete withModularHeaders.js plugin - Delete firebase.json - Delete associated test files * test(config): remove Firebase-related tests from app.config Firebase configuration was removed in the Amplitude migration. Updated tests to reflect the new configuration structure. * docs(changelog): add Amplitude Analytics migration entry Added: - Amplitude Analytics integration with native/web platform support - 35+ Title Case analytics events for user engagement tracking - 9 user properties for cohort analysis Changed: - Firebase Analytics replaced with Amplitude SDK - Analytics module architecture updated with platform-specific implementations Removed: - Firebase Analytics dependencies and configuration files * test(tasks): add accessibility tests for TaskCard status icons and due dates * docs(changelog): add TaskCard accessibility tests and bug fix entries * test(analytics): improve coverage for platform modules - Add tests for uninitialized state (setUserId, setUserProperties, reset) - Add test for undefined property values - Add test for default screen class in trackScreenView * docs(changelog): add analytics platform test coverage entry * fix(analytics): rethrow initialization errors to enable retry Platform implementations were catching errors and not rethrowing them, causing initializeAnalytics() to set state='completed' even on failure. This prevented retry and caused silent analytics failures. - Rethrow errors from initializePlatformAnalytics in both platforms - Add tests for error propagation behavior * fix(analytics): remove unreachable code and trim API key - Remove dead code: API key warning was unreachable after shouldInitializeAnalytics check - Trim API key in config to match validation behavior - Update JSDoc example to use AnalyticsEvents constant - Remove obsolete test for removed warning 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Bill Chirico * refactor(analytics): remove unreachable API key warning shouldInitializeAnalytics() already checks for API key existence, so the warning at lines 128-131 was unreachable dead code. - Remove redundant !config.apiKey check - Use non-null assertion since key is guaranteed by shouldInitializeAnalytics() - Remove test for unreachable code path * fix(analytics): handle negative values in calculateStepsCompletedBucket Negative step counts now correctly return '0' bucket instead of falling through to '10-12'. Added test cases for negative values. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * docs(changelog): add entry for negative bucket fix 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * test(analytics): replace fragile exact event count with sanity check Per PR review: hardcoded event count (37) is fragile and breaks when events are legitimately added/removed. Changed to verify events are defined (>0). * fix(analytics): add error handling to trackEventPlatform Per PR review: wrap amplitude.track() in try-catch to prevent unexpected exceptions from propagating, matching the pattern in setUserPropertiesPlatform. * fix(analytics): add consistent error handling to setUserIdPlatform and resetAnalyticsPlatform Per PR review: apply consistent error handling pattern across all Amplitude API calls to prevent unexpected exceptions from propagating. * fix(analytics): properly await Amplitude init() using .promise pattern Per PR review: Amplitude SDK v2+ requires calling .promise on init() return value to properly await initialization. Without this, tracking may occur before initialization completes, causing potential data loss. * fix(analytics): update Amplitude mocks to use .promise pattern - Update jest.setup.js mocks for @amplitude/analytics-browser and @amplitude/analytics-react-native to return { promise: Promise.resolve() } from init() to match the actual SDK behavior - Update test files to use mockReturnValueOnce with promise rejection instead of mockRejectedValueOnce for init error tests * fix(docs): update CHANGELOG comparison links to reference v1.2.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * chore(deps): add Sentry Spotlight for local dev debugging - Install @spotlightjs/spotlight as dev dependency - Add pnpm spotlight script to run the sidecar server - Spotlight captures Sentry events locally in development Usage: Run `pnpm spotlight` in a separate terminal before `pnpm web` * test(analytics): add missing AnalyticsEvents constant assertions Cover all 35 event constants in analytics.test.ts to catch any renamed or removed constants. Added assertions for: - ONBOARDING_STEP_COMPLETED, ONBOARDING_SOBRIETY_DATE_SET - SCREEN_VIEWED, TASK_VIEWED, TASK_STARTED - MILESTONE_SHARED - SPONSOR_CONNECTED, SPONSOR_INVITE_SENT, SPONSOR_INVITE_ACCEPTED - MESSAGE_SENT, APP_OPENED, DAILY_CHECK_IN * docs(changelog): consolidate duplicate Removed sections 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * 📝 Add docstrings to `feat/amplitude-analytics` (#231) Docstrings generation was requested by @BillChirico. * https://github.com/VolvoxCommunity/sobers/pull/229#issuecomment-3697528812 The following files were modified: * `lib/analytics-utils.ts` * `lib/analytics/index.ts` * `lib/analytics/platform.native.ts` * `lib/analytics/platform.web.ts` Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * feat(analytics): track SAVINGS_UPDATED event when editing savings - Add trackEvent call in handleSave with amount, frequency, is_first_setup - Include isSetupMode in useCallback dependency array * fix(analytics): add missing JSDoc closing delimiter in sanitizeValue The JSDoc comment for the sanitizeValue function was missing its closing `*/` delimiter, causing TypeScript to interpret the function declaration as part of the comment and fail with TS2304: Cannot find name 'sanitizeValue'. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * feat(analytics): add tracking for engagement, settings, and onboarding events Add analytics tracking for: - APP_OPENED, APP_BACKGROUNDED, APP_SESSION_STARTED, DAILY_CHECK_IN (engagement) - SETTINGS_CHANGED for theme and dashboard preferences - ONBOARDING_ABANDONED when user signs out during onboarding - SAVINGS_UPDATED when editing savings settings Also: - Add AppState mock to jest.setup.js for testing - Add AnalyticsEvents to analytics mock in layout tests * fix: update comment to reference Amplitude instead of Firebase Co-authored-by: Bill Chirico * feat(analytics): add comprehensive onboarding analytics tracking Add tracking for all 7 onboarding events: - ONBOARDING_STARTED: on mount - ONBOARDING_SCREEN_VIEWED: on mount with screen_name - ONBOARDING_FIELD_COMPLETED: for display_name, sobriety_date, savings_tracking - ONBOARDING_SOBRIETY_DATE_SET: when date is changed (existing) - ONBOARDING_STEP_COMPLETED: on completion with step_name, step_number - ONBOARDING_COMPLETED: on completion with duration_seconds, savings_enabled - ONBOARDING_ABANDONED: on sign out (added previously) Uses refs to prevent duplicate field completion events. --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Bill Chirico Co-authored-by: Claude Opus 4.5 Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .env.example | 29 +- CHANGELOG.md | 29 +- __tests__/app/layout.test.tsx | 1 + __tests__/components/tasks/TaskCard.test.tsx | 78 + __tests__/config/app.config.test.ts | 73 - __tests__/lib/analytics-utils.test.ts | 818 +------ __tests__/lib/analytics.native.test.ts | 531 +--- __tests__/lib/analytics.test.ts | 63 +- __tests__/lib/analytics.web.test.ts | 954 +------- __tests__/plugins/withFirebaseConfig.test.ts | 482 ---- __tests__/plugins/withModularHeaders.test.ts | 521 ---- __tests__/types/analytics.test.ts | 148 +- app.config.ts | 46 +- app/_layout.tsx | 67 +- app/onboarding.tsx | 53 +- components/settings/SettingsContent.tsx | 33 +- components/sheets/EditSavingsSheet.tsx | 10 +- components/tasks/TaskCard.tsx | 35 +- ...2-26-amplitude-analytics-implementation.md | 1655 +++++++++++++ firebase.json | 6 - jest.setup.js | 35 + lib/analytics-utils.ts | 133 +- lib/analytics/index.ts | 57 +- lib/analytics/platform.native.ts | 307 +-- lib/analytics/platform.web.ts | 527 +--- package.json | 7 +- plugins/withFirebaseConfig.js | 250 -- plugins/withModularHeaders.js | 53 - pnpm-lock.yaml | 2170 ++++++++++------- types/analytics.ts | 110 +- 30 files changed, 3984 insertions(+), 5297 deletions(-) delete mode 100644 __tests__/plugins/withFirebaseConfig.test.ts delete mode 100644 __tests__/plugins/withModularHeaders.test.ts create mode 100644 docs/plans/2025-12-26-amplitude-analytics-implementation.md delete mode 100644 firebase.json delete mode 100644 plugins/withFirebaseConfig.js delete mode 100644 plugins/withModularHeaders.js diff --git a/.env.example b/.env.example index 2982c58d..36997842 100644 --- a/.env.example +++ b/.env.example @@ -62,32 +62,11 @@ GOOGLE_SECRET=your-google-client-secret # APPLE_CLIENT_ID=your-apple-service-id-here # ============================================================================== -# FIREBASE ANALYTICS +# AMPLITUDE ANALYTICS # ============================================================================== -# Firebase Console: https://console.firebase.google.com/project/sobers -# -# --- Web Platform (env vars) --- -# Get these from: Firebase Console > Project Settings > Your apps > Web app -EXPO_PUBLIC_FIREBASE_API_KEY= -EXPO_PUBLIC_FIREBASE_PROJECT_ID= -EXPO_PUBLIC_FIREBASE_APP_ID= -EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID= - -# --- Native Platforms (config files - NOT committed to git) --- -# Download from Firebase Console > Project Settings > Your apps -# -# iOS: GoogleService-Info.plist -# 1. Download from Firebase Console -# 2. Place in project root: ./GoogleService-Info.plist -# 3. Run `npx expo prebuild` to copy to ios/ -# -# Android: google-services.json -# 1. Download from Firebase Console -# 2. Place in project root: ./google-services.json -# 3. Run `npx expo prebuild` to copy to android/app/ -# -# IMPORTANT: These files contain API keys and are gitignored. -# Each developer must download their own copy from Firebase Console. +# Amplitude Dashboard: https://app.amplitude.com +# Get API key from: Settings > Projects > [Your Project] > General +EXPO_PUBLIC_AMPLITUDE_API_KEY=your-amplitude-api-key-here # Optional: Enable debug mode for analytics (shows events in console) EXPO_PUBLIC_ANALYTICS_DEBUG=false diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d5afec..0a8f6414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add Amplitude Analytics integration for product analytics with native and web platform support +- Add 35+ analytics events with Title Case naming (e.g., "Screen Viewed", "Task Completed") for comprehensive user engagement tracking +- Add 9 user properties for cohort analysis: days_sober_bucket, steps_completed_bucket, has_sponsor, has_sponsees, theme_preference, notifications_enabled, app_version, platform, device_type - Add password visibility toggle to Login and Signup screens with accessible labels - Add testIDs for password toggle buttons to improve test selectability - Add tests for password visibility toggle functionality in login and signup screens @@ -18,20 +21,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add Build Info conditional rendering tests for SettingsContent - Add handleSaveName edge case tests for SettingsContent - Add handleToggleSavingsCard tests for SettingsContent +- Add TaskCard accessibility tests for status icon labels and due date announcements +- Add analytics platform tests for uninitialized state and edge cases +- Add Sentry Spotlight for local development error debugging (`pnpm spotlight`) +- Add complete AnalyticsEvents constant assertions covering all 35 event types to catch renamed/removed constants +- Add comprehensive onboarding analytics tracking (ONBOARDING_STARTED, ONBOARDING_SCREEN_VIEWED, ONBOARDING_FIELD_COMPLETED, ONBOARDING_STEP_COMPLETED, ONBOARDING_SOBRIETY_DATE_SET, ONBOARDING_COMPLETED, ONBOARDING_ABANDONED) +- Add analytics tracking for settings changes (SETTINGS_CHANGED event for theme and dashboard preferences) +- Add analytics tracking for app engagement (APP_OPENED, APP_BACKGROUNDED, APP_SESSION_STARTED, DAILY_CHECK_IN events) +- Add analytics tracking for savings updates (SAVINGS_UPDATED event when editing savings settings) ### Changed +- Replace Firebase Analytics with Amplitude SDK for improved cross-platform analytics support +- Update analytics module architecture with platform-specific implementations (native/web) using Metro bundler resolution - Lower branch coverage threshold from 85% to 83% to account for untestable code paths (DevToolsSection, platform-specific conditionals) +### Removed + +- Remove Firebase Analytics dependencies (@react-native-firebase/analytics, @react-native-firebase/app) +- Remove Firebase configuration files and plugins (firebase.json, withFirebaseConfig.js, withModularHeaders.js) +- Remove check for updates feature and expo-updates dependency from the app + ### Fixed - Fix E2E savings tests failing due to incorrect card click (menu button required) - Improve test coverage: add tests for alert module public API, SettingsContent build info, and savings card toggle - Fix keyboard pushing content up excessively in EditSavingsSheet by using single snap point - -### Removed - -- Remove check for updates feature and expo-updates dependency from the app +- Fix TaskCard syntax error causing build failure (broken JSX from accessibility enhancement) +- Fix analytics initialization errors being swallowed, preventing retry on failure +- Fix calculateStepsCompletedBucket returning incorrect bucket for negative values ## [1.2.1] - 2025-12-25 @@ -152,7 +170,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Initial release -[Unreleased]: https://github.com/VolvoxCommunity/sobers/compare/v1.2.0...HEAD +[Unreleased]: https://github.com/VolvoxCommunity/sobers/compare/v1.2.1...HEAD +[1.2.1]: https://github.com/VolvoxCommunity/sobers/compare/v1.2.0...v1.2.1 [1.2.0]: https://github.com/VolvoxCommunity/sobers/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/VolvoxCommunity/sobers/compare/v1.0.0...v1.1.0 [1.0.1]: https://github.com/VolvoxCommunity/sobers/compare/v1.0.0...v1.0.1 diff --git a/__tests__/app/layout.test.tsx b/__tests__/app/layout.test.tsx index c0babf64..224f8b27 100644 --- a/__tests__/app/layout.test.tsx +++ b/__tests__/app/layout.test.tsx @@ -140,6 +140,7 @@ jest.mock('@/lib/analytics', () => ({ setUserId: jest.fn(), setUserProperties: jest.fn(), resetAnalytics: jest.fn(), + AnalyticsEvents: jest.requireActual('@/types/analytics').AnalyticsEvents, })); // ============================================================================= diff --git a/__tests__/components/tasks/TaskCard.test.tsx b/__tests__/components/tasks/TaskCard.test.tsx index a10b61fd..643f6e24 100644 --- a/__tests__/components/tasks/TaskCard.test.tsx +++ b/__tests__/components/tasks/TaskCard.test.tsx @@ -334,6 +334,84 @@ describe('TaskCard', () => { const deleteButton = screen.getByLabelText('Delete task Complete Step 1'); expect(deleteButton).toBeTruthy(); }); + + describe('Status Icon Labels', () => { + it('announces completed status for my-task variant', () => { + render(); + + expect(screen.getByLabelText('Status: Completed')).toBeTruthy(); + }); + + it('announces completed status for managed-task variant', () => { + const completedTask = { ...mockTask, status: 'completed' as const }; + + render(); + + expect(screen.getByLabelText('Status: Completed')).toBeTruthy(); + }); + + it('announces overdue status for managed-task variant', () => { + render( + + ); + + expect(screen.getByLabelText('Status: Overdue')).toBeTruthy(); + }); + + it('announces assigned status for managed-task variant', () => { + const assignedTask = { ...mockTask, status: 'assigned' as const }; + + render( + + ); + + expect(screen.getByLabelText('Status: Assigned')).toBeTruthy(); + }); + + it('announces in progress status for managed-task variant', () => { + const inProgressTask = { ...mockTask, status: 'in_progress' as const }; + + render( + + ); + + expect(screen.getByLabelText('Status: In Progress')).toBeTruthy(); + }); + }); + + describe('Due Date Labels', () => { + it('announces due date for my-task variant', () => { + render(); + + expect(screen.getByLabelText(/^Due /)).toBeTruthy(); + }); + + it('announces due date for managed-task variant', () => { + render(); + + expect(screen.getByLabelText(/^Due /)).toBeTruthy(); + }); + + it('announces overdue due date for managed-task variant', () => { + render( + + ); + + expect(screen.getByLabelText(/^Due .*, Overdue$/)).toBeTruthy(); + }); + }); }); describe('Edge Cases', () => { diff --git a/__tests__/config/app.config.test.ts b/__tests__/config/app.config.test.ts index 6608874b..65d249af 100644 --- a/__tests__/config/app.config.test.ts +++ b/__tests__/config/app.config.test.ts @@ -5,7 +5,6 @@ * - Plugin configuration * - Build settings * - Configuration structure - * - Firebase configuration via environment variables */ import appConfig from '../../app.config'; @@ -135,76 +134,4 @@ describe('app.config.ts', () => { expect(config.updates).toBeDefined(); }); }); - - describe('Firebase Configuration', () => { - describe('iOS googleServicesFile', () => { - it('uses environment variable when GOOGLE_SERVICE_INFO_PLIST is set', () => { - process.env.GOOGLE_SERVICE_INFO_PLIST = '/tmp/eas-secret/GoogleService-Info.plist'; - - const config = appConfig({ config: {} } as any); - - expect(config.ios?.googleServicesFile).toBe('/tmp/eas-secret/GoogleService-Info.plist'); - }); - - it('falls back to local file when GOOGLE_SERVICE_INFO_PLIST is not set', () => { - delete process.env.GOOGLE_SERVICE_INFO_PLIST; - - const config = appConfig({ config: {} } as any); - - expect(config.ios?.googleServicesFile).toBe('./GoogleService-Info.plist'); - }); - - it('falls back to local file when GOOGLE_SERVICE_INFO_PLIST is empty string', () => { - process.env.GOOGLE_SERVICE_INFO_PLIST = ''; - - const config = appConfig({ config: {} } as any); - - // Empty string is falsy, so it should fall back to local file - expect(config.ios?.googleServicesFile).toBe('./GoogleService-Info.plist'); - }); - }); - - describe('Android googleServicesFile', () => { - it('uses environment variable when GOOGLE_SERVICES_JSON is set', () => { - process.env.GOOGLE_SERVICES_JSON = '/tmp/eas-secret/google-services.json'; - - const config = appConfig({ config: {} } as any); - - expect(config.android?.googleServicesFile).toBe('/tmp/eas-secret/google-services.json'); - }); - - it('falls back to local file when GOOGLE_SERVICES_JSON is not set', () => { - delete process.env.GOOGLE_SERVICES_JSON; - - const config = appConfig({ config: {} } as any); - - expect(config.android?.googleServicesFile).toBe('./google-services.json'); - }); - - it('falls back to local file when GOOGLE_SERVICES_JSON is empty string', () => { - process.env.GOOGLE_SERVICES_JSON = ''; - - const config = appConfig({ config: {} } as any); - - // Empty string is falsy, so it should fall back to local file - expect(config.android?.googleServicesFile).toBe('./google-services.json'); - }); - }); - - describe('Firebase plugin configuration', () => { - it('includes @react-native-firebase/app plugin', () => { - const config = appConfig({ config: {} } as any); - - const hasFirebasePlugin = config.plugins?.includes('@react-native-firebase/app'); - expect(hasFirebasePlugin).toBe(true); - }); - - it('includes withModularHeaders plugin for Firebase compatibility', () => { - const config = appConfig({ config: {} } as any); - - const hasModularHeadersPlugin = config.plugins?.includes('./plugins/withModularHeaders'); - expect(hasModularHeadersPlugin).toBe(true); - }); - }); - }); }); diff --git a/__tests__/lib/analytics-utils.test.ts b/__tests__/lib/analytics-utils.test.ts index c2d37b59..aa6e6c49 100644 --- a/__tests__/lib/analytics-utils.test.ts +++ b/__tests__/lib/analytics-utils.test.ts @@ -1,799 +1,171 @@ // __tests__/lib/analytics-utils.test.ts -import { Platform } from 'react-native'; - import { sanitizeParams, calculateDaysSoberBucket, + calculateStepsCompletedBucket, shouldInitializeAnalytics, isDebugMode, getAnalyticsEnvironment, } from '@/lib/analytics-utils'; -import type { DaysSoberBucket } from '@/types/analytics'; - -// Store original Platform.OS to restore after tests -const originalPlatformOS = Platform.OS; -describe('Analytics Utilities', () => { +describe('lib/analytics-utils', () => { describe('sanitizeParams', () => { - it('passes through valid parameters unchanged', () => { - const params = { - task_id: '123', - count: 5, - is_active: true, - }; - expect(sanitizeParams(params)).toEqual(params); + it('should return empty object for undefined', () => { + expect(sanitizeParams(undefined)).toEqual({}); }); - it('strips email field from parameters', () => { + it('should remove PII fields', () => { const params = { task_id: '123', - email: 'user@example.com', + email: 'test@example.com', + name: 'John', + count: 5, }; - expect(sanitizeParams(params)).toEqual({ task_id: '123' }); + expect(sanitizeParams(params)).toEqual({ task_id: '123', count: 5 }); }); - it('strips all PII fields', () => { + it('should handle nested objects', () => { const params = { task_id: '123', - email: 'user@example.com', - name: 'John Doe', - display_name: 'John D.', - phone: '555-1234', - password: 'secret', - token: 'abc123', + metadata: { email: 'test@example.com', value: 42 }, }; - expect(sanitizeParams(params)).toEqual({ task_id: '123' }); + expect(sanitizeParams(params)).toEqual({ + task_id: '123', + metadata: { value: 42 }, + }); }); - it('handles undefined values', () => { + it('should handle arrays', () => { const params = { - task_id: '123', - optional: undefined, + items: [{ email: 'a@b.com', id: 1 }, { id: 2 }], }; expect(sanitizeParams(params)).toEqual({ - task_id: '123', - optional: undefined, + items: [{ id: 1 }, { id: 2 }], }); }); - - it('returns empty object for empty input', () => { - expect(sanitizeParams({})).toEqual({}); - }); - - it('returns empty object for undefined input', () => { - expect(sanitizeParams(undefined)).toEqual({}); - }); }); describe('calculateDaysSoberBucket', () => { - it('returns "0-7" for 0 days', () => { - expect(calculateDaysSoberBucket(0)).toBe('0-7'); - }); - - it('returns "0-7" for 7 days', () => { - expect(calculateDaysSoberBucket(7)).toBe('0-7'); - }); - - it('returns "8-30" for 8 days', () => { - expect(calculateDaysSoberBucket(8)).toBe('8-30'); - }); - - it('returns "8-30" for 30 days', () => { - expect(calculateDaysSoberBucket(30)).toBe('8-30'); - }); - - it('returns "31-90" for 31 days', () => { - expect(calculateDaysSoberBucket(31)).toBe('31-90'); + it.each([ + [0, '0-7'], + [7, '0-7'], + [8, '8-30'], + [30, '8-30'], + [31, '31-90'], + [90, '31-90'], + [91, '91-180'], + [180, '91-180'], + [181, '181-365'], + [365, '181-365'], + [366, '365+'], + [1000, '365+'], + ])('should return %s for %d days', (days, expected) => { + expect(calculateDaysSoberBucket(days)).toBe(expected); }); + }); - it('returns "31-90" for 90 days', () => { - expect(calculateDaysSoberBucket(90)).toBe('31-90'); + describe('calculateStepsCompletedBucket', () => { + it.each([ + [-5, '0'], + [-1, '0'], + [0, '0'], + [1, '1-3'], + [3, '1-3'], + [4, '4-6'], + [6, '4-6'], + [7, '7-9'], + [9, '7-9'], + [10, '10-12'], + [12, '10-12'], + ])('should return %s for %d steps', (count, expected) => { + expect(calculateStepsCompletedBucket(count)).toBe(expected); }); + }); - it('returns "91-180" for 91 days', () => { - expect(calculateDaysSoberBucket(91)).toBe('91-180'); - }); + describe('shouldInitializeAnalytics', () => { + const originalEnv = process.env; - it('returns "91-180" for 180 days', () => { - expect(calculateDaysSoberBucket(180)).toBe('91-180'); + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; }); - it('returns "181-365" for 181 days', () => { - expect(calculateDaysSoberBucket(181)).toBe('181-365'); + afterAll(() => { + process.env = originalEnv; }); - it('returns "181-365" for 365 days', () => { - expect(calculateDaysSoberBucket(365)).toBe('181-365'); + it('should return true when API key is set', () => { + process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY = 'test-key'; + // Re-import to pick up env change + const { shouldInitializeAnalytics: freshFn } = require('@/lib/analytics-utils'); + expect(freshFn()).toBe(true); }); - it('returns "365+" for 366 days', () => { - expect(calculateDaysSoberBucket(366)).toBe('365+'); + it('should return false when API key is empty', () => { + process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY = ''; + expect(shouldInitializeAnalytics()).toBe(false); }); - it('returns "365+" for 1000 days', () => { - expect(calculateDaysSoberBucket(1000)).toBe('365+'); + it('should return false when API key is whitespace', () => { + process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY = ' '; + expect(shouldInitializeAnalytics()).toBe(false); }); - it('handles negative days as "0-7"', () => { - expect(calculateDaysSoberBucket(-5)).toBe('0-7'); + it('should return false when API key is not set', () => { + delete process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY; + expect(shouldInitializeAnalytics()).toBe(false); }); }); - describe('shouldInitializeAnalytics', () => { + describe('isDebugMode', () => { const originalEnv = process.env; - afterEach(() => { - // Restore Platform.OS after each test - (Platform as { OS: string }).OS = originalPlatformOS; + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; }); afterAll(() => { process.env = originalEnv; }); - describe('on native platforms (iOS/Android)', () => { - it('returns true on iOS regardless of env vars', () => { - (Platform as { OS: string }).OS = 'ios'; - const originalValue = process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID; - delete process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID; - - expect(shouldInitializeAnalytics()).toBe(true); - - // Restore - if (originalValue !== undefined) { - process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID = originalValue; - } - }); - - it('returns true on Android regardless of env vars', () => { - (Platform as { OS: string }).OS = 'android'; - const originalValue = process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID; - delete process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID; - - expect(shouldInitializeAnalytics()).toBe(true); - - // Restore - if (originalValue !== undefined) { - process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID = originalValue; - } - }); - }); - - describe('on web platform', () => { - beforeEach(() => { - (Platform as { OS: string }).OS = 'web'; - }); - - it('returns true when Firebase measurement ID is set', () => { - const originalValue = process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID; - process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID = 'G-XXXXXXXX'; - - expect(shouldInitializeAnalytics()).toBe(true); - - // Restore original value - if (originalValue === undefined) { - delete process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID; - } else { - process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID = originalValue; - } - }); - - it('returns false when Firebase measurement ID is missing', () => { - const originalValue = process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID; - delete process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID; - - expect(shouldInitializeAnalytics()).toBe(false); - - // Restore original value - if (originalValue !== undefined) { - process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID = originalValue; - } - }); - - it('returns false when Firebase measurement ID is empty string', () => { - const originalValue = process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID; - process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID = ''; - - expect(shouldInitializeAnalytics()).toBe(false); - - // Restore original value - if (originalValue === undefined) { - delete process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID; - } else { - process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID = originalValue; - } - }); - }); - }); - - describe('isDebugMode', () => { - it('returns false when __DEV__ is false and no env var set', () => { - const originalValue = process.env.EXPO_PUBLIC_ANALYTICS_DEBUG; + it('should return false when __DEV__ is false and no env var set', () => { + // __DEV__ is false in test environment (jest.setup.js) delete process.env.EXPO_PUBLIC_ANALYTICS_DEBUG; - - // __DEV__ is false in Jest test environment expect(isDebugMode()).toBe(false); - - // Restore - if (originalValue !== undefined) { - process.env.EXPO_PUBLIC_ANALYTICS_DEBUG = originalValue; - } }); - it('returns true when EXPO_PUBLIC_ANALYTICS_DEBUG is set to true', () => { - const originalValue = process.env.EXPO_PUBLIC_ANALYTICS_DEBUG; + it('should return true when EXPO_PUBLIC_ANALYTICS_DEBUG is true', () => { process.env.EXPO_PUBLIC_ANALYTICS_DEBUG = 'true'; - - expect(isDebugMode()).toBe(true); - - // Restore - if (originalValue === undefined) { - delete process.env.EXPO_PUBLIC_ANALYTICS_DEBUG; - } else { - process.env.EXPO_PUBLIC_ANALYTICS_DEBUG = originalValue; - } - }); - - it('returns false when EXPO_PUBLIC_ANALYTICS_DEBUG is not "true"', () => { - const originalValue = process.env.EXPO_PUBLIC_ANALYTICS_DEBUG; - process.env.EXPO_PUBLIC_ANALYTICS_DEBUG = 'false'; - - expect(isDebugMode()).toBe(false); - - // Restore - if (originalValue === undefined) { - delete process.env.EXPO_PUBLIC_ANALYTICS_DEBUG; - } else { - process.env.EXPO_PUBLIC_ANALYTICS_DEBUG = originalValue; - } + // Re-import to pick up env change + const { isDebugMode: freshFn } = require('@/lib/analytics-utils'); + expect(freshFn()).toBe(true); }); }); describe('getAnalyticsEnvironment', () => { - it('returns production when __DEV__ is false and no env var', () => { - const originalValue = process.env.EXPO_PUBLIC_APP_ENV; - delete process.env.EXPO_PUBLIC_APP_ENV; - - // __DEV__ is false in Jest, so it returns the env var or 'production' - expect(getAnalyticsEnvironment()).toBe('production'); - - // Restore - if (originalValue !== undefined) { - process.env.EXPO_PUBLIC_APP_ENV = originalValue; - } - }); - - it('returns EXPO_PUBLIC_APP_ENV when set', () => { - const originalValue = process.env.EXPO_PUBLIC_APP_ENV; - process.env.EXPO_PUBLIC_APP_ENV = 'staging'; - - expect(getAnalyticsEnvironment()).toBe('staging'); - - // Restore - if (originalValue === undefined) { - delete process.env.EXPO_PUBLIC_APP_ENV; - } else { - process.env.EXPO_PUBLIC_APP_ENV = originalValue; - } - }); - }); - - describe('sanitizeParams - additional edge cases', () => { - it('strips access_token field', () => { - const params = { - task_id: '123', - access_token: 'secret-token-123', - }; - expect(sanitizeParams(params)).toEqual({ task_id: '123' }); - }); - - it('strips refresh_token field', () => { - const params = { - task_id: '123', - refresh_token: 'refresh-token-456', - }; - expect(sanitizeParams(params)).toEqual({ task_id: '123' }); - }); - - it('strips sobriety_date field', () => { - const params = { - task_id: '123', - sobriety_date: '2024-01-01', - }; - expect(sanitizeParams(params)).toEqual({ task_id: '123' }); - }); - - it('strips relapse_date field', () => { - const params = { - task_id: '123', - relapse_date: '2024-06-01', - }; - expect(sanitizeParams(params)).toEqual({ task_id: '123' }); - }); - - it('strips multiple PII fields while preserving valid fields', () => { - const params = { - event_name: 'task_completed', - task_id: '123', - email: 'user@test.com', - display_name: 'John D.', - timestamp: Date.now(), - phone: '555-1234', - count: 42, - password: 'secret123', - }; - expect(sanitizeParams(params)).toEqual({ - event_name: 'task_completed', - task_id: '123', - timestamp: params.timestamp, - count: 42, - }); - }); - - it('handles null values in parameters', () => { - const params = { - task_id: '123', - optional: null, - }; - expect(sanitizeParams(params)).toEqual({ - task_id: '123', - optional: null, - }); - }); - - it('handles boolean values', () => { - const params = { - is_active: true, - is_deleted: false, - }; - expect(sanitizeParams(params)).toEqual({ - is_active: true, - is_deleted: false, - }); - }); - - it('handles numeric zero', () => { - const params = { - count: 0, - value: 0, - }; - expect(sanitizeParams(params)).toEqual({ - count: 0, - value: 0, - }); - }); - - it('handles empty strings in valid fields', () => { - const params = { - task_id: '', - description: '', - }; - expect(sanitizeParams(params)).toEqual({ - task_id: '', - description: '', - }); - }); - - it('handles nested objects by preserving them', () => { - const params = { - task_id: '123', - metadata: { - nested_field: 'value', - }, - }; - expect(sanitizeParams(params)).toEqual({ - task_id: '123', - metadata: { - nested_field: 'value', - }, - }); - }); - - it('strips PII from nested objects', () => { - const params = { - task_id: '123', - email: 'should@be.removed', - metadata: { - email: 'also.should@be.removed', - name: 'Should be removed', - }, - }; - const result = sanitizeParams(params); - expect(result.task_id).toBe('123'); - expect(result.email).toBeUndefined(); - expect(result.metadata).toBeDefined(); - expect((result.metadata as Record)?.email).toBeUndefined(); - expect((result.metadata as Record)?.name).toBeUndefined(); - }); - - it('handles array values', () => { - const params = { - task_ids: ['123', '456', '789'], - tags: ['tag1', 'tag2'], - }; - expect(sanitizeParams(params)).toEqual({ - task_ids: ['123', '456', '789'], - tags: ['tag1', 'tag2'], - }); - }); - - it('strips PII from nested arrays of objects', () => { - const params = { - task_id: '123', - users: [ - { id: '1', email: 'user1@test.com', name: 'User One' }, - { id: '2', email: 'user2@test.com', name: 'User Two' }, - ], - }; - const result = sanitizeParams(params); - expect(result.task_id).toBe('123'); - expect(Array.isArray(result.users)).toBe(true); - const users = result.users as Record[]; - expect(users[0]?.id).toBe('1'); - expect(users[0]?.email).toBeUndefined(); - expect(users[0]?.name).toBeUndefined(); - expect(users[1]?.id).toBe('2'); - expect(users[1]?.email).toBeUndefined(); - expect(users[1]?.name).toBeUndefined(); - }); - - it('strips PII from deeply nested objects', () => { - const params = { - task_id: '123', - level1: { - level2: { - level3: { - email: 'deep@nested.com', - name: 'Deep Nested', - phone: '555-1234', - }, - }, - }, - }; - const result = sanitizeParams(params); - expect(result.task_id).toBe('123'); - const level1 = result.level1 as Record; - const level2 = level1?.level2 as Record; - const level3 = level2?.level3 as Record; - expect(level3?.email).toBeUndefined(); - expect(level3?.name).toBeUndefined(); - expect(level3?.phone).toBeUndefined(); - }); - - it('handles circular references gracefully', () => { - const params: Record = { - task_id: '123', - metadata: { - email: 'should@be.removed', - }, - }; - // Create a circular reference - params.metadata = params; - const result = sanitizeParams(params); - expect(result.task_id).toBe('123'); - // Circular reference should be handled (undefined or removed) - expect(result.metadata).toBeUndefined(); - }); - - it('strips PII from arrays containing objects with nested PII', () => { - const params = { - events: [ - { type: 'click', metadata: { email: 'user@test.com' } }, - { type: 'view', metadata: { name: 'John Doe' } }, - ], - }; - const result = sanitizeParams(params); - const events = result.events as Record[]; - expect(events[0]?.type).toBe('click'); - expect((events[0]?.metadata as Record)?.email).toBeUndefined(); - expect(events[1]?.type).toBe('view'); - expect((events[1]?.metadata as Record)?.name).toBeUndefined(); - }); - - it('preserves non-PII fields in nested structures', () => { - const params = { - task_id: '123', - metadata: { - email: 'should@be.removed', - name: 'Should be removed', - description: 'This should remain', - count: 42, - is_active: true, - }, - }; - const result = sanitizeParams(params); - expect(result.task_id).toBe('123'); - const metadata = result.metadata as Record; - expect(metadata?.email).toBeUndefined(); - expect(metadata?.name).toBeUndefined(); - expect(metadata?.description).toBe('This should remain'); - expect(metadata?.count).toBe(42); - expect(metadata?.is_active).toBe(true); - }); - - it('strips all PII field types from nested objects', () => { - const params = { - task_id: '123', - user_data: { - email: 'user@test.com', - name: 'John Doe', - display_name: 'John D.', - phone: '555-1234', - password: 'secret', - token: 'abc123', - access_token: 'token123', - refresh_token: 'refresh123', - sobriety_date: '2024-01-01', - relapse_date: '2024-06-01', - safe_field: 'keep this', - }, - }; - const result = sanitizeParams(params); - expect(result.task_id).toBe('123'); - const userData = result.user_data as Record; - expect(userData?.email).toBeUndefined(); - expect(userData?.name).toBeUndefined(); - expect(userData?.display_name).toBeUndefined(); - expect(userData?.phone).toBeUndefined(); - expect(userData?.password).toBeUndefined(); - expect(userData?.token).toBeUndefined(); - expect(userData?.access_token).toBeUndefined(); - expect(userData?.refresh_token).toBeUndefined(); - expect(userData?.sobriety_date).toBeUndefined(); - expect(userData?.relapse_date).toBeUndefined(); - expect(userData?.safe_field).toBe('keep this'); - }); - - it('handles mixed nested structures with arrays and objects', () => { - const params = { - task_id: '123', - items: [ - { - id: '1', - metadata: { - email: 'user1@test.com', - tags: ['tag1', 'tag2'], - }, - }, - { - id: '2', - metadata: { - name: 'User Two', - nested: { - phone: '555-1234', - }, - }, - }, - ], - }; - const result = sanitizeParams(params); - expect(result.task_id).toBe('123'); - const items = result.items as Record[]; - const item1Meta = items[0]?.metadata as Record; - expect(item1Meta?.email).toBeUndefined(); - expect(item1Meta?.tags).toEqual(['tag1', 'tag2']); - const item2Meta = items[1]?.metadata as Record; - expect(item2Meta?.name).toBeUndefined(); - const nested = item2Meta?.nested as Record; - expect(nested?.phone).toBeUndefined(); - }); - }); - - describe('calculateDaysSoberBucket - additional edge cases', () => { - it('handles fractional days', () => { - // Implementation uses <= comparison, so 7.9 <= 7 is false, goes to 8-30 - // Similarly, 30.5 <= 30 is false, goes to 31-90 - expect(calculateDaysSoberBucket(7.9)).toBe('8-30'); - expect(calculateDaysSoberBucket(30.5)).toBe('31-90'); - }); - - it('handles very large numbers', () => { - expect(calculateDaysSoberBucket(10000)).toBe('365+'); - expect(calculateDaysSoberBucket(Number.MAX_SAFE_INTEGER)).toBe('365+'); - }); - - it('handles zero', () => { - expect(calculateDaysSoberBucket(0)).toBe('0-7'); - }); - - it('handles boundary values for each bucket', () => { - // Test exact boundaries - const boundaries = [ - { days: 7, expected: '0-7' }, - { days: 8, expected: '8-30' }, - { days: 30, expected: '8-30' }, - { days: 31, expected: '31-90' }, - { days: 90, expected: '31-90' }, - { days: 91, expected: '91-180' }, - { days: 180, expected: '91-180' }, - { days: 181, expected: '181-365' }, - { days: 365, expected: '181-365' }, - { days: 366, expected: '365+' }, - ]; - - boundaries.forEach(({ days, expected }) => { - expect(calculateDaysSoberBucket(days)).toBe(expected); - }); - }); - - it('handles mid-range values for each bucket', () => { - expect(calculateDaysSoberBucket(4)).toBe('0-7'); - expect(calculateDaysSoberBucket(15)).toBe('8-30'); - expect(calculateDaysSoberBucket(60)).toBe('31-90'); - expect(calculateDaysSoberBucket(135)).toBe('91-180'); - expect(calculateDaysSoberBucket(273)).toBe('181-365'); - expect(calculateDaysSoberBucket(500)).toBe('365+'); - }); - - it('returns valid DaysSoberBucket value', () => { - const result = calculateDaysSoberBucket(45); - const validBuckets: DaysSoberBucket[] = ['0-7', '8-30', '31-90', '91-180', '181-365', '365+']; - expect(validBuckets).toContain(result); - }); - }); - - describe('shouldInitializeAnalytics - additional edge cases', () => { - afterEach(() => { - (Platform as { OS: string }).OS = originalPlatformOS; - }); - - it('handles unknown platform values', () => { - (Platform as { OS: string }).OS = 'unknown' as any; - // Unknown platforms should behave like native (return true) - expect(shouldInitializeAnalytics()).toBe(true); - }); - - it('handles Windows platform', () => { - (Platform as { OS: string }).OS = 'windows' as any; - // Non-web platforms should return true - expect(shouldInitializeAnalytics()).toBe(true); - }); - - it('handles macOS platform', () => { - (Platform as { OS: string }).OS = 'macos' as any; - // Non-web platforms should return true - expect(shouldInitializeAnalytics()).toBe(true); - }); - - describe('web platform with various env var states', () => { - beforeEach(() => { - (Platform as { OS: string }).OS = 'web'; - }); - - it('returns false for whitespace-only measurement ID', () => { - const originalValue = process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID; - process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID = ' '; - - expect(shouldInitializeAnalytics()).toBe(false); - - if (originalValue === undefined) { - delete process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID; - } else { - process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID = originalValue; - } - }); - - it('returns true for valid Firebase measurement ID format', () => { - const originalValue = process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID; - process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID = 'G-ABC123XYZ'; - - expect(shouldInitializeAnalytics()).toBe(true); - - if (originalValue === undefined) { - delete process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID; - } else { - process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID = originalValue; - } - }); - }); - }); - - describe('isDebugMode - additional edge cases', () => { - it('returns false for EXPO_PUBLIC_ANALYTICS_DEBUG set to "false" string', () => { - const originalValue = process.env.EXPO_PUBLIC_ANALYTICS_DEBUG; - process.env.EXPO_PUBLIC_ANALYTICS_DEBUG = 'false'; - - expect(isDebugMode()).toBe(false); - - if (originalValue === undefined) { - delete process.env.EXPO_PUBLIC_ANALYTICS_DEBUG; - } else { - process.env.EXPO_PUBLIC_ANALYTICS_DEBUG = originalValue; - } - }); - - it('returns false for EXPO_PUBLIC_ANALYTICS_DEBUG set to "0"', () => { - const originalValue = process.env.EXPO_PUBLIC_ANALYTICS_DEBUG; - process.env.EXPO_PUBLIC_ANALYTICS_DEBUG = '0'; - - expect(isDebugMode()).toBe(false); - - if (originalValue === undefined) { - delete process.env.EXPO_PUBLIC_ANALYTICS_DEBUG; - } else { - process.env.EXPO_PUBLIC_ANALYTICS_DEBUG = originalValue; - } - }); - - it('returns false for empty string EXPO_PUBLIC_ANALYTICS_DEBUG', () => { - const originalValue = process.env.EXPO_PUBLIC_ANALYTICS_DEBUG; - process.env.EXPO_PUBLIC_ANALYTICS_DEBUG = ''; - - expect(isDebugMode()).toBe(false); + const originalEnv = process.env; - if (originalValue === undefined) { - delete process.env.EXPO_PUBLIC_ANALYTICS_DEBUG; - } else { - process.env.EXPO_PUBLIC_ANALYTICS_DEBUG = originalValue; - } + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; }); - it('returns true only for exact string "true"', () => { - const testCases = [ - { value: 'true', expected: true }, - { value: 'True', expected: false }, - { value: 'TRUE', expected: false }, - { value: '1', expected: false }, - { value: 'yes', expected: false }, - ]; - - testCases.forEach(({ value, expected }) => { - const originalValue = process.env.EXPO_PUBLIC_ANALYTICS_DEBUG; - process.env.EXPO_PUBLIC_ANALYTICS_DEBUG = value; - - expect(isDebugMode()).toBe(expected); - - if (originalValue === undefined) { - delete process.env.EXPO_PUBLIC_ANALYTICS_DEBUG; - } else { - process.env.EXPO_PUBLIC_ANALYTICS_DEBUG = originalValue; - } - }); + afterAll(() => { + process.env = originalEnv; }); - }); - - describe('getAnalyticsEnvironment - additional edge cases', () => { - it('returns production for empty string EXPO_PUBLIC_APP_ENV', () => { - const originalValue = process.env.EXPO_PUBLIC_APP_ENV; - process.env.EXPO_PUBLIC_APP_ENV = ''; + it('should return production when __DEV__ is false and no env var', () => { + // __DEV__ is false in test environment (jest.setup.js) + delete process.env.EXPO_PUBLIC_APP_ENV; expect(getAnalyticsEnvironment()).toBe('production'); - - if (originalValue === undefined) { - delete process.env.EXPO_PUBLIC_APP_ENV; - } else { - process.env.EXPO_PUBLIC_APP_ENV = originalValue; - } - }); - - it('returns custom environment names', () => { - const envNames = ['development', 'staging', 'production', 'test', 'preview']; - - envNames.forEach((envName) => { - const originalValue = process.env.EXPO_PUBLIC_APP_ENV; - process.env.EXPO_PUBLIC_APP_ENV = envName; - - expect(getAnalyticsEnvironment()).toBe(envName); - - if (originalValue === undefined) { - delete process.env.EXPO_PUBLIC_APP_ENV; - } else { - process.env.EXPO_PUBLIC_APP_ENV = originalValue; - } - }); }); - it('preserves case of environment name', () => { - const originalValue = process.env.EXPO_PUBLIC_APP_ENV; - process.env.EXPO_PUBLIC_APP_ENV = 'PRODUCTION'; - - expect(getAnalyticsEnvironment()).toBe('PRODUCTION'); - - if (originalValue === undefined) { - delete process.env.EXPO_PUBLIC_APP_ENV; - } else { - process.env.EXPO_PUBLIC_APP_ENV = originalValue; - } + it('should return EXPO_PUBLIC_APP_ENV when set', () => { + process.env.EXPO_PUBLIC_APP_ENV = 'staging'; + // Re-import to pick up env change + const { getAnalyticsEnvironment: freshFn } = require('@/lib/analytics-utils'); + expect(freshFn()).toBe('staging'); }); }); }); diff --git a/__tests__/lib/analytics.native.test.ts b/__tests__/lib/analytics.native.test.ts index 977725a0..e6ec751c 100644 --- a/__tests__/lib/analytics.native.test.ts +++ b/__tests__/lib/analytics.native.test.ts @@ -1,515 +1,166 @@ -// Define mock functions that will be accessible inside jest.mock factory -import { - initializePlatformAnalytics as initializeNativeAnalytics, - trackEventPlatform as trackEventNative, - setUserIdPlatform as setUserIdNative, - setUserPropertiesPlatform as setUserPropertiesNative, - trackScreenViewPlatform as trackScreenViewNative, - resetAnalyticsPlatform as resetAnalyticsNative, -} from '@/lib/analytics/platform.native'; +// __tests__/lib/analytics.native.test.ts +/** + * @jest-environment node + */ -const mockIsDebugMode = jest.fn(() => false); -const mockLoggerInfo = jest.fn(); -const mockLoggerDebug = jest.fn(); -const mockLoggerError = jest.fn(); -const mockLoggerWarn = jest.fn(); +import * as amplitude from '@amplitude/analytics-react-native'; -// Mock analytics-utils - use wrapper to avoid hoisting issues -jest.mock('@/lib/analytics-utils', () => ({ - isDebugMode: () => mockIsDebugMode(), -})); +import { + initializePlatformAnalytics, + trackEventPlatform, + setUserIdPlatform, + setUserPropertiesPlatform, + trackScreenViewPlatform, + resetAnalyticsPlatform, + __resetForTesting, +} from '@/lib/analytics/platform.native'; -// Mock logger - use wrapper functions to avoid hoisting issues jest.mock('@/lib/logger', () => ({ logger: { - info: (...args: unknown[]) => mockLoggerInfo(...args), - debug: (...args: unknown[]) => mockLoggerDebug(...args), - error: (...args: unknown[]) => mockLoggerError(...args), - warn: (...args: unknown[]) => mockLoggerWarn(...args), - }, - LogCategory: { - ANALYTICS: 'ANALYTICS', + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), }, + LogCategory: { ANALYTICS: 'analytics' }, })); -// Mock @react-native-firebase/analytics modular API -const mockLogEvent = jest.fn(() => Promise.resolve()); -const mockSetUserId = jest.fn(() => Promise.resolve()); -const mockSetUserProperties = jest.fn(() => Promise.resolve()); -const mockResetAnalyticsData = jest.fn(() => Promise.resolve()); -const mockSetAnalyticsCollectionEnabled = jest.fn(() => Promise.resolve()); -const mockGetAnalytics = jest.fn(() => ({ _instance: 'mock-analytics' })); - -// Mock modular API - named exports instead of default export -jest.mock('@react-native-firebase/analytics', () => { - return { - __esModule: true, - getAnalytics: () => mockGetAnalytics(), - logEvent: (_analytics: unknown, ...args: unknown[]) => mockLogEvent(...args), - setUserId: (_analytics: unknown, ...args: unknown[]) => mockSetUserId(...args), - setUserProperties: (_analytics: unknown, ...args: unknown[]) => mockSetUserProperties(...args), - resetAnalyticsData: (_analytics: unknown, ...args: unknown[]) => - mockResetAnalyticsData(...args), - setAnalyticsCollectionEnabled: (_analytics: unknown, ...args: unknown[]) => - mockSetAnalyticsCollectionEnabled(...args), - }; -}); - -// Helper to flush promise queue for fire-and-forget error handling -const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); - -describe('Native Analytics', () => { +describe('lib/analytics/platform.native', () => { beforeEach(() => { jest.clearAllMocks(); - mockIsDebugMode.mockReturnValue(false); - mockGetAnalytics.mockReturnValue({ _instance: 'mock-analytics' }); + __resetForTesting(); }); - describe('initializeNativeAnalytics', () => { - it('completes without error', async () => { - await expect(initializeNativeAnalytics()).resolves.not.toThrow(); - }); - - it('always enables analytics collection', async () => { - mockIsDebugMode.mockReturnValue(false); - - await initializeNativeAnalytics(); - - expect(mockSetAnalyticsCollectionEnabled).toHaveBeenCalledWith(true); - }); - - it('logs success in debug mode', async () => { - mockIsDebugMode.mockReturnValue(true); + describe('initializePlatformAnalytics', () => { + it('should initialize Amplitude with API key', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - await initializeNativeAnalytics(); - - expect(mockLoggerInfo).toHaveBeenCalledWith( - 'Firebase Analytics initialized for native', - expect.objectContaining({ category: 'ANALYTICS' }) - ); + expect(amplitude.init).toHaveBeenCalledWith('test-key', undefined, expect.any(Object)); }); - it('does not log success when not in debug mode', async () => { - mockIsDebugMode.mockReturnValue(false); + it('should not reinitialize if already initialized', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + await initializePlatformAnalytics({ apiKey: 'test-key' }); - await initializeNativeAnalytics(); - - expect(mockLoggerInfo).not.toHaveBeenCalled(); + expect(amplitude.init).toHaveBeenCalledTimes(1); }); - it('handles initialization errors gracefully', async () => { - const error = new Error('Init failed'); - mockSetAnalyticsCollectionEnabled.mockRejectedValueOnce(error); + it('should rethrow initialization errors to allow retry', async () => { + const mockError = new Error('Network error'); + (amplitude.init as jest.Mock).mockReturnValueOnce({ + promise: Promise.reject(mockError), + }); - await expect(initializeNativeAnalytics()).resolves.not.toThrow(); - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to initialize native analytics', - error, - expect.objectContaining({ category: 'ANALYTICS' }) + await expect(initializePlatformAnalytics({ apiKey: 'test-key' })).rejects.toThrow( + 'Network error' ); - }); - - it('handles non-Error initialization errors', async () => { - mockSetAnalyticsCollectionEnabled.mockRejectedValueOnce('string error'); - mockIsDebugMode.mockReturnValue(true); - await expect(initializeNativeAnalytics()).resolves.not.toThrow(); - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to initialize native analytics', - expect.any(Error), - expect.objectContaining({ category: 'ANALYTICS' }) - ); + // Verify init was called + expect(amplitude.init).toHaveBeenCalled(); }); }); - describe('trackEventNative', () => { - it('calls logEvent with event name and params', () => { - trackEventNative('test_event', { param1: 'value1' }); + describe('trackEventPlatform', () => { + it('should track event after initialization', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - expect(mockLogEvent).toHaveBeenCalledWith('test_event', { param1: 'value1' }); - }); + trackEventPlatform('Test Event', { param: 'value' }); - it('calls logEvent without params when none provided', () => { - trackEventNative('test_event'); - - expect(mockLogEvent).toHaveBeenCalledWith('test_event', undefined); + expect(amplitude.track).toHaveBeenCalledWith('Test Event', { param: 'value' }); }); - it('logs event in debug mode', () => { - mockIsDebugMode.mockReturnValue(true); - - trackEventNative('test_event', { param1: 'value1' }); + it('should not track event before initialization', () => { + trackEventPlatform('Test Event', { param: 'value' }); - expect(mockLoggerDebug).toHaveBeenCalledWith( - 'Event: test_event', - expect.objectContaining({ category: 'ANALYTICS', param1: 'value1' }) - ); - }); - - it('handles tracking errors gracefully', async () => { - const error = new Error('Track failed'); - mockLogEvent.mockRejectedValueOnce(error); - - // Function returns void (fire-and-forget), doesn't throw - expect(() => trackEventNative('test_event')).not.toThrow(); - - // Wait for the promise rejection to be caught and logged - await flushPromises(); - - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to track event test_event', - error, - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - - it('handles non-Error tracking errors', async () => { - mockLogEvent.mockRejectedValueOnce('string error'); - - expect(() => trackEventNative('test_event')).not.toThrow(); - - await flushPromises(); - - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to track event test_event', - expect.any(Error), - expect.objectContaining({ category: 'ANALYTICS' }) - ); + expect(amplitude.track).not.toHaveBeenCalled(); }); }); - describe('setUserIdNative', () => { - it('sets user ID', () => { - setUserIdNative('user-123'); + describe('setUserIdPlatform', () => { + it('should set user ID after initialization', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - expect(mockSetUserId).toHaveBeenCalledWith('user-123'); - }); - - it('clears user ID when null', () => { - setUserIdNative(null); + setUserIdPlatform('user-123'); - expect(mockSetUserId).toHaveBeenCalledWith(null); + expect(amplitude.setUserId).toHaveBeenCalledWith('user-123'); }); - it('logs hashed user ID in debug mode', () => { - mockIsDebugMode.mockReturnValue(true); + it('should clear user ID when null', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - setUserIdNative('user-123'); + setUserIdPlatform(null); - // User ID is hashed for privacy in logs - expect(mockLoggerDebug).toHaveBeenCalledWith( - expect.stringMatching(/^setUserId: $/), - expect.objectContaining({ category: 'ANALYTICS' }) - ); + expect(amplitude.setUserId).toHaveBeenCalledWith(undefined); }); - it('handles errors gracefully', async () => { - const error = new Error('SetUserId failed'); - mockSetUserId.mockRejectedValueOnce(error); - - expect(() => setUserIdNative('user-123')).not.toThrow(); + it('should not set user ID before initialization', () => { + setUserIdPlatform('user-123'); - await flushPromises(); - - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to set user ID', - error, - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - - it('handles non-Error setUserId errors', async () => { - mockSetUserId.mockRejectedValueOnce('string error'); - - expect(() => setUserIdNative('user-123')).not.toThrow(); - - await flushPromises(); - - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to set user ID', - expect.any(Error), - expect.objectContaining({ category: 'ANALYTICS' }) - ); + expect(amplitude.setUserId).not.toHaveBeenCalled(); }); }); - describe('setUserPropertiesNative', () => { - it('sets user properties with string values', () => { - const props = { theme_preference: 'dark' }; - setUserPropertiesNative(props); - - expect(mockSetUserProperties).toHaveBeenCalledWith({ theme_preference: 'dark' }); - }); - - it('converts boolean values to strings', () => { - const props = { has_sponsor: true, is_premium: false }; - setUserPropertiesNative(props); - - expect(mockSetUserProperties).toHaveBeenCalledWith({ - has_sponsor: 'true', - is_premium: 'false', - }); - }); - - it('handles null values', () => { - const props = { theme_preference: null }; - setUserPropertiesNative(props); - - expect(mockSetUserProperties).toHaveBeenCalledWith({ theme_preference: null }); - }); - - it('skips undefined values', () => { - const props = { theme_preference: 'dark', other: undefined }; - setUserPropertiesNative(props); - - expect(mockSetUserProperties).toHaveBeenCalledWith({ theme_preference: 'dark' }); - }); - - it('logs in debug mode', () => { - mockIsDebugMode.mockReturnValue(true); - const props = { theme_preference: 'dark' }; + describe('setUserPropertiesPlatform', () => { + it('should set user properties after initialization', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - setUserPropertiesNative(props); + setUserPropertiesPlatform({ has_sponsor: true, theme_preference: 'dark' }); - expect(mockLoggerDebug).toHaveBeenCalledWith( - 'setUserProperties', - expect.objectContaining({ category: 'ANALYTICS', theme_preference: 'dark' }) - ); + expect(amplitude.identify).toHaveBeenCalled(); }); - it('handles errors gracefully', async () => { - const error = new Error('SetUserProperties failed'); - mockSetUserProperties.mockRejectedValueOnce(error); - - expect(() => setUserPropertiesNative({ theme_preference: 'dark' })).not.toThrow(); + it('should not set user properties before initialization', () => { + setUserPropertiesPlatform({ has_sponsor: true }); - await flushPromises(); - - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to set user properties', - error, - expect.objectContaining({ category: 'ANALYTICS' }) - ); + expect(amplitude.identify).not.toHaveBeenCalled(); }); - it('handles non-Error errors', async () => { - mockSetUserProperties.mockRejectedValueOnce('string error'); + it('should skip undefined property values', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - expect(() => setUserPropertiesNative({ theme_preference: 'dark' })).not.toThrow(); + setUserPropertiesPlatform({ has_sponsor: true, theme_preference: undefined }); - await flushPromises(); - - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to set user properties', - expect.any(Error), - expect.objectContaining({ category: 'ANALYTICS' }) - ); + expect(amplitude.identify).toHaveBeenCalled(); }); }); - describe('trackScreenViewNative', () => { - it('logs screen view with name using logEvent', () => { - trackScreenViewNative('HomeScreen'); + describe('trackScreenViewPlatform', () => { + it('should track screen view event', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - // Uses logEvent with 'screen_view' instead of deprecated logScreenView - expect(mockLogEvent).toHaveBeenCalledWith('screen_view', { - screen_name: 'HomeScreen', - screen_class: 'HomeScreen', - }); - }); + trackScreenViewPlatform('Home', 'TabScreen'); - it('logs screen view with name and class using logEvent', () => { - trackScreenViewNative('HomeScreen', 'TabScreen'); - - expect(mockLogEvent).toHaveBeenCalledWith('screen_view', { - screen_name: 'HomeScreen', + expect(amplitude.track).toHaveBeenCalledWith('Screen Viewed', { + screen_name: 'Home', screen_class: 'TabScreen', }); }); - it('logs in debug mode', () => { - mockIsDebugMode.mockReturnValue(true); - - trackScreenViewNative('HomeScreen'); - - expect(mockLoggerDebug).toHaveBeenCalledWith( - 'Screen view: HomeScreen', - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - - it('handles errors gracefully', async () => { - const error = new Error('Screen view tracking failed'); - mockLogEvent.mockRejectedValueOnce(error); - - expect(() => trackScreenViewNative('HomeScreen')).not.toThrow(); - - await flushPromises(); - - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to track screen view', - error, - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - - it('handles non-Error errors', async () => { - mockLogEvent.mockRejectedValueOnce('string error'); - - expect(() => trackScreenViewNative('HomeScreen')).not.toThrow(); - - await flushPromises(); - - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to track screen view', - expect.any(Error), - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - }); + it('should use screen name as default screen class', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - describe('resetAnalyticsNative', () => { - it('resets analytics data', async () => { - await resetAnalyticsNative(); + trackScreenViewPlatform('Settings'); - expect(mockResetAnalyticsData).toHaveBeenCalled(); - }); - - it('logs in debug mode', async () => { - mockIsDebugMode.mockReturnValue(true); - - await resetAnalyticsNative(); - - expect(mockLoggerInfo).toHaveBeenCalledWith( - 'Resetting analytics state', - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - - it('handles errors gracefully', async () => { - const error = new Error('Reset failed'); - mockResetAnalyticsData.mockRejectedValueOnce(error); - - await expect(resetAnalyticsNative()).resolves.not.toThrow(); - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to reset analytics', - error, - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - - it('handles non-Error errors', async () => { - mockResetAnalyticsData.mockRejectedValueOnce('string error'); - - await expect(resetAnalyticsNative()).resolves.not.toThrow(); - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to reset analytics', - expect.any(Error), - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - }); - - describe('when Firebase is unavailable', () => { - // Use jest.isolateModules to get fresh module state with failing getAnalytics - const importWithFailingAnalytics = ( - modulePath: string, - callback: (mod: T) => void | Promise - ): Promise => { - return new Promise((resolve, reject) => { - jest.isolateModules(() => { - // Configure getAnalytics to throw before importing the module - mockGetAnalytics.mockImplementation(() => { - throw new Error('Firebase not configured'); - }); - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const mod = require(modulePath) as T; - const result = callback(mod); - if (result instanceof Promise) { - result.then(resolve).catch(reject); - } else { - resolve(); - } - } catch (error) { - reject(error); - } - }); + expect(amplitude.track).toHaveBeenCalledWith('Screen Viewed', { + screen_name: 'Settings', + screen_class: 'Settings', }); - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('initializeNativeAnalytics warns when analytics unavailable', async () => { - await importWithFailingAnalytics( - '@/lib/analytics/platform.native', - async (mod) => { - await mod.initializePlatformAnalytics(); - - expect(mockLoggerWarn).toHaveBeenCalledWith( - 'Analytics not available - Firebase may not be configured', - expect.objectContaining({ category: 'ANALYTICS' }) - ); - } - ); }); + }); - it('trackEventPlatform silently returns when analytics unavailable', async () => { - await importWithFailingAnalytics( - '@/lib/analytics/platform.native', - (mod) => { - // Should not throw - expect(() => mod.trackEventPlatform('test_event')).not.toThrow(); - // Firebase logEvent should not be called - expect(mockLogEvent).not.toHaveBeenCalled(); - } - ); - }); + describe('resetAnalyticsPlatform', () => { + it('should reset analytics state', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - it('setUserIdPlatform silently returns when analytics unavailable', async () => { - await importWithFailingAnalytics( - '@/lib/analytics/platform.native', - (mod) => { - expect(() => mod.setUserIdPlatform('user-123')).not.toThrow(); - expect(mockSetUserId).not.toHaveBeenCalled(); - } - ); - }); + await resetAnalyticsPlatform(); - it('setUserPropertiesPlatform silently returns when analytics unavailable', async () => { - await importWithFailingAnalytics( - '@/lib/analytics/platform.native', - (mod) => { - expect(() => mod.setUserPropertiesPlatform({ theme: 'dark' })).not.toThrow(); - expect(mockSetUserProperties).not.toHaveBeenCalled(); - } - ); + expect(amplitude.reset).toHaveBeenCalled(); }); - it('trackScreenViewPlatform silently returns when analytics unavailable', async () => { - await importWithFailingAnalytics( - '@/lib/analytics/platform.native', - (mod) => { - expect(() => mod.trackScreenViewPlatform('HomeScreen')).not.toThrow(); - expect(mockLogEvent).not.toHaveBeenCalled(); - } - ); - }); + it('should not reset before initialization', async () => { + await resetAnalyticsPlatform(); - it('resetAnalyticsPlatform warns when analytics unavailable', async () => { - await importWithFailingAnalytics( - '@/lib/analytics/platform.native', - async (mod) => { - await mod.resetAnalyticsPlatform(); - - expect(mockLoggerWarn).toHaveBeenCalledWith( - 'Cannot reset analytics - Firebase not available', - expect.objectContaining({ category: 'ANALYTICS' }) - ); - } - ); + expect(amplitude.reset).not.toHaveBeenCalled(); }); }); }); diff --git a/__tests__/lib/analytics.test.ts b/__tests__/lib/analytics.test.ts index 279a2221..570ceb81 100644 --- a/__tests__/lib/analytics.test.ts +++ b/__tests__/lib/analytics.test.ts @@ -10,6 +10,7 @@ import { resetAnalytics, AnalyticsEvents, calculateDaysSoberBucket, + calculateStepsCompletedBucket, __resetForTesting, } from '@/lib/analytics'; @@ -23,15 +24,6 @@ const mockResetAnalyticsPlatform = jest.fn(() => Promise.resolve()); const mockSanitizeParams = jest.fn((params) => params); const mockShouldInitializeAnalytics = jest.fn(() => true); const mockIsDebugMode = jest.fn(() => false); -const mockLoggerWarn = jest.fn(); - -// Mock react-native Platform -jest.mock('react-native', () => ({ - Platform: { - OS: 'ios', - select: jest.fn((options: { web?: unknown; default?: unknown }) => options.default), - }, -})); // Mock the platform implementation module jest.mock('@/lib/analytics/platform', () => ({ @@ -49,12 +41,13 @@ jest.mock('@/lib/analytics-utils', () => ({ shouldInitializeAnalytics: () => mockShouldInitializeAnalytics(), isDebugMode: () => mockIsDebugMode(), calculateDaysSoberBucket: jest.fn(() => '31-90'), + calculateStepsCompletedBucket: jest.fn(() => '4-6'), })); // Mock logger jest.mock('@/lib/logger', () => ({ logger: { - warn: (...args: unknown[]) => mockLoggerWarn(...args), + warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn(), @@ -74,14 +67,15 @@ describe('Unified Analytics Module', () => { }); describe('initializeAnalytics', () => { - it('skips initialization when Firebase not configured', async () => { + it('skips initialization when Amplitude not configured', async () => { mockShouldInitializeAnalytics.mockReturnValue(false); mockIsDebugMode.mockReturnValue(true); await initializeAnalytics(); - expect(mockLoggerWarn).toHaveBeenCalledWith( - 'Firebase not configured - analytics disabled', + const { logger } = jest.requireMock('@/lib/logger'); + expect(logger.warn).toHaveBeenCalledWith( + 'Amplitude not configured - analytics disabled', expect.any(Object) ); expect(mockInitializePlatformAnalytics).not.toHaveBeenCalled(); @@ -90,14 +84,10 @@ describe('Unified Analytics Module', () => { it('initializes platform analytics when configured', async () => { await initializeAnalytics(); - expect(mockInitializePlatformAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - apiKey: expect.any(String), - projectId: expect.any(String), - appId: expect.any(String), - measurementId: expect.any(String), - }) - ); + // Verify platform init was called with a config object containing apiKey + expect(mockInitializePlatformAnalytics).toHaveBeenCalledTimes(1); + const config = mockInitializePlatformAnalytics.mock.calls[0][0]; + expect(config).toHaveProperty('apiKey'); }); it('returns immediately when already completed (with debug logging)', async () => { @@ -182,32 +172,6 @@ describe('Unified Analytics Module', () => { expect(mockInitializePlatformAnalytics).toHaveBeenCalledTimes(1); }); - - it('warns about missing Firebase config on web platform', async () => { - // Mock Platform.OS as 'web' - const { Platform } = jest.requireMock('react-native'); - const originalOS = Platform.OS; - Platform.OS = 'web'; - - try { - await initializeAnalytics(); - - // Should warn about missing config (env vars are empty in test) - expect(mockLoggerWarn).toHaveBeenCalledWith( - 'Firebase config incomplete - some required values are missing', - expect.objectContaining({ - category: 'ANALYTICS', - hasApiKey: expect.any(Boolean), - hasProjectId: expect.any(Boolean), - hasAppId: expect.any(Boolean), - hasMeasurementId: expect.any(Boolean), - }) - ); - } finally { - // Restore Platform.OS - Platform.OS = originalOS; - } - }); }); describe('trackEvent', () => { @@ -290,6 +254,11 @@ describe('Unified Analytics Module', () => { expect(calculateDaysSoberBucket).toBeDefined(); expect(typeof calculateDaysSoberBucket).toBe('function'); }); + + it('re-exports calculateStepsCompletedBucket', () => { + expect(calculateStepsCompletedBucket).toBeDefined(); + expect(typeof calculateStepsCompletedBucket).toBe('function'); + }); }); describe('__resetForTesting', () => { diff --git a/__tests__/lib/analytics.web.test.ts b/__tests__/lib/analytics.web.test.ts index 2f194eb6..15a94b7c 100644 --- a/__tests__/lib/analytics.web.test.ts +++ b/__tests__/lib/analytics.web.test.ts @@ -1,948 +1,162 @@ -/** - * @jest-environment jsdom - */ +// __tests__/lib/analytics.web.test.ts +import * as amplitude from '@amplitude/analytics-browser'; -// Define mock functions that will be accessible inside jest.mock factory -// Import after mocks are set up -import { initializeApp, getApps } from 'firebase/app'; import { - getAnalytics, - logEvent, - setUserId, - setUserProperties, - isSupported, -} from 'firebase/analytics'; - -import { - initializePlatformAnalytics as initializeWebAnalytics, - trackEventPlatform as trackEventWeb, - setUserIdPlatform as setUserIdWeb, - setUserPropertiesPlatform as setUserPropertiesWeb, - trackScreenViewPlatform as trackScreenViewWeb, - resetAnalyticsPlatform as resetAnalyticsWeb, + initializePlatformAnalytics, + trackEventPlatform, + setUserIdPlatform, + setUserPropertiesPlatform, + trackScreenViewPlatform, + resetAnalyticsPlatform, __resetForTesting, } from '@/lib/analytics/platform.web'; -const mockLoggerWarn = jest.fn(); -const mockLoggerInfo = jest.fn(); -const mockLoggerDebug = jest.fn(); -const mockLoggerError = jest.fn(); -const mockIsDebugMode = jest.fn(() => false); - -// Mock analytics-utils - must be before imports -jest.mock('@/lib/analytics-utils', () => ({ - isDebugMode: () => mockIsDebugMode(), -})); - -// Mock logger - use wrapper functions to avoid hoisting issues jest.mock('@/lib/logger', () => ({ logger: { - warn: (...args: unknown[]) => mockLoggerWarn(...args), - info: (...args: unknown[]) => mockLoggerInfo(...args), - debug: (...args: unknown[]) => mockLoggerDebug(...args), - error: (...args: unknown[]) => mockLoggerError(...args), - }, - LogCategory: { - ANALYTICS: 'ANALYTICS', + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), }, + LogCategory: { ANALYTICS: 'analytics' }, })); -jest.mock('firebase/app', () => ({ - initializeApp: jest.fn(() => ({ name: 'test-app' })), - getApps: jest.fn(() => []), - getApp: jest.fn(() => ({ name: 'existing-app' })), -})); - -jest.mock('firebase/analytics', () => ({ - getAnalytics: jest.fn(() => ({ app: { name: 'test-app' } })), - logEvent: jest.fn(), - setUserId: jest.fn(), - setUserProperties: jest.fn(), - isSupported: jest.fn(() => Promise.resolve(true)), -})); - -describe('Web Analytics', () => { +describe('lib/analytics/platform.web', () => { beforeEach(() => { - // Reset module state to allow re-initialization between tests - __resetForTesting(); jest.clearAllMocks(); - (getApps as jest.Mock).mockReturnValue([]); - (isSupported as jest.Mock).mockResolvedValue(true); - mockIsDebugMode.mockReturnValue(false); - }); - - describe('initializeWebAnalytics', () => { - const mockConfig = { - apiKey: 'test-key', - projectId: 'test-project', - appId: 'test-app-id', - measurementId: 'G-XXXXXXXX', - }; - - it('initializes Firebase app with config', async () => { - await initializeWebAnalytics(mockConfig); - - expect(initializeApp).toHaveBeenCalledWith( - expect.objectContaining({ - apiKey: 'test-key', - projectId: 'test-project', - appId: 'test-app-id', - measurementId: 'G-XXXXXXXX', - }) - ); - }); - - it('initializes analytics after app', async () => { - await initializeWebAnalytics(mockConfig); - - expect(getAnalytics).toHaveBeenCalled(); - }); - - it('logs success in debug mode', async () => { - mockIsDebugMode.mockReturnValue(true); - - await initializeWebAnalytics(mockConfig); - - expect(mockLoggerInfo).toHaveBeenCalledWith( - 'Firebase Analytics initialized for web', - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - - it('does not reinitialize if app already exists', async () => { - (getApps as jest.Mock).mockReturnValue([{ name: 'existing' }]); - - await initializeWebAnalytics(mockConfig); - - expect(initializeApp).not.toHaveBeenCalled(); - }); - - it('logs message when app already exists in debug mode', async () => { - (getApps as jest.Mock).mockReturnValue([{ name: 'existing' }]); - mockIsDebugMode.mockReturnValue(true); - - await initializeWebAnalytics(mockConfig); - - expect(mockLoggerInfo).toHaveBeenCalledWith( - 'Firebase app already initialized, retrieved existing instance', - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - - it('handles unsupported browsers gracefully', async () => { - (isSupported as jest.Mock).mockResolvedValue(false); - - await expect(initializeWebAnalytics(mockConfig)).resolves.not.toThrow(); - expect(initializeApp).not.toHaveBeenCalled(); - }); - - it('logs warning for unsupported browsers in debug mode', async () => { - (isSupported as jest.Mock).mockResolvedValue(false); - mockIsDebugMode.mockReturnValue(true); - - await initializeWebAnalytics(mockConfig); - - expect(mockLoggerWarn).toHaveBeenCalledWith( - 'Firebase Analytics not supported in this browser', - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - - it('handles initialization errors gracefully', async () => { - const error = new Error('Init failed'); - (initializeApp as jest.Mock).mockImplementationOnce(() => { - throw error; - }); - - await expect(initializeWebAnalytics(mockConfig)).resolves.not.toThrow(); - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to initialize Firebase Analytics', - error, - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - - it('handles non-Error initialization errors', async () => { - (initializeApp as jest.Mock).mockImplementationOnce(() => { - throw 'string error'; - }); - - await expect(initializeWebAnalytics(mockConfig)).resolves.not.toThrow(); - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to initialize Firebase Analytics', - expect.any(Error), - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - - it('returns immediately when already initialized (with debug logging)', async () => { - mockIsDebugMode.mockReturnValue(true); - - // First call initializes - await initializeWebAnalytics(mockConfig); - jest.clearAllMocks(); - - // Second call should return immediately without reinitializing - await initializeWebAnalytics(mockConfig); - - expect(initializeApp).not.toHaveBeenCalled(); - expect(mockLoggerDebug).toHaveBeenCalledWith( - 'Analytics already initialized, skipping re-initialization', - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - - it('concurrent calls await the same Promise', async () => { - // Create a deferred promise to control timing - let resolveSupported: (value: boolean) => void; - const supportedPromise = new Promise((resolve) => { - resolveSupported = resolve; - }); - (isSupported as jest.Mock).mockReturnValue(supportedPromise); - - // Start two concurrent initializations - const call1 = initializeWebAnalytics(mockConfig); - const call2 = initializeWebAnalytics(mockConfig); - - // isSupported should only be called once (first call starts initialization) - expect(isSupported).toHaveBeenCalledTimes(1); - - // Resolve and wait for both - resolveSupported!(true); - await Promise.all([call1, call2]); - }); - - it('logs waiting message for concurrent calls in debug mode', async () => { - mockIsDebugMode.mockReturnValue(true); - - // Create a deferred promise to control timing - let resolveSupported: (value: boolean) => void; - const supportedPromise = new Promise((resolve) => { - resolveSupported = resolve; - }); - (isSupported as jest.Mock).mockReturnValue(supportedPromise); - - // Start two concurrent initializations - const call1 = initializeWebAnalytics(mockConfig); - const call2 = initializeWebAnalytics(mockConfig); - - // Second call should log waiting message - expect(mockLoggerDebug).toHaveBeenCalledWith( - 'Analytics initialization in progress, waiting...', - expect.objectContaining({ category: 'ANALYTICS' }) - ); - - // Resolve and wait for both - resolveSupported!(true); - await Promise.all([call1, call2]); - }); - - it('gracefully handles Firebase failure and marks initialization complete', async () => { - // Firebase init fails, but initialization still completes (graceful degradation) - (isSupported as jest.Mock).mockResolvedValueOnce(true); - (initializeApp as jest.Mock).mockImplementationOnce(() => { - throw new Error('Firebase init failed'); - }); - - await initializeWebAnalytics(mockConfig); - - // Error should be logged - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to initialize Firebase Analytics', - expect.any(Error), - expect.objectContaining({ category: 'ANALYTICS' }) - ); - - // Initialization should still be marked complete (graceful degradation) - // Subsequent calls should return early - jest.clearAllMocks(); - mockIsDebugMode.mockReturnValue(true); - - await initializeWebAnalytics(mockConfig); - - // Should log that initialization is already complete - expect(mockLoggerDebug).toHaveBeenCalledWith( - 'Analytics already initialized, skipping re-initialization', - expect.objectContaining({ category: 'ANALYTICS' }) - ); - expect(initializeApp).not.toHaveBeenCalled(); - }); - }); - - describe('trackEventWeb', () => { - beforeEach(async () => { - // Initialize analytics first - await initializeWebAnalytics({ - apiKey: 'test-key', - projectId: 'test-project', - appId: 'test-app-id', - measurementId: 'G-XXXXXXXX', - }); - jest.clearAllMocks(); - }); - - it('calls logEvent with event name and params', () => { - trackEventWeb('test_event', { param1: 'value1' }); - - expect(logEvent).toHaveBeenCalledWith(expect.anything(), 'test_event', { param1: 'value1' }); - }); - - it('calls logEvent without params when none provided', () => { - trackEventWeb('test_event'); - - expect(logEvent).toHaveBeenCalledWith(expect.anything(), 'test_event', undefined); - }); - - it('logs event in debug mode', () => { - mockIsDebugMode.mockReturnValue(true); - - trackEventWeb('test_event', { param1: 'value1' }); - - // Implementation wraps params in event_params for structured logging - expect(mockLoggerDebug).toHaveBeenCalledWith( - 'Event: test_event', - expect.objectContaining({ - category: 'ANALYTICS', - event_params: expect.objectContaining({ param1: 'value1' }), - }) - ); - }); - }); - - describe('setUserIdWeb', () => { - beforeEach(async () => { - await initializeWebAnalytics({ - apiKey: 'test-key', - projectId: 'test-project', - appId: 'test-app-id', - measurementId: 'G-XXXXXXXX', - }); - jest.clearAllMocks(); - }); - - it('sets user ID', () => { - setUserIdWeb('user-123'); - - expect(setUserId).toHaveBeenCalledWith(expect.anything(), 'user-123'); - }); - - it('clears user ID when null', () => { - setUserIdWeb(null); - - expect(setUserId).toHaveBeenCalledWith(expect.anything(), null); - }); - - it('logs in debug mode with hashed userId', async () => { - mockIsDebugMode.mockReturnValue(true); - - setUserIdWeb('user-123'); - - // Wait for the async hash operation to complete (flush promises) - await new Promise((resolve) => setTimeout(resolve, 0)); - - // Implementation hashes userId for privacy-safe logging - // In test environment crypto may not be available, so [hash-error] is also valid - expect(mockLoggerDebug).toHaveBeenCalledWith( - expect.stringMatching(/^setUserId: $/), - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - }); - - describe('setUserPropertiesWeb', () => { - beforeEach(async () => { - await initializeWebAnalytics({ - apiKey: 'test-key', - projectId: 'test-project', - appId: 'test-app-id', - measurementId: 'G-XXXXXXXX', - }); - jest.clearAllMocks(); - }); - - it('sets user properties', () => { - const props = { theme_preference: 'dark' as const }; - setUserPropertiesWeb(props); - - expect(setUserProperties).toHaveBeenCalledWith(expect.anything(), props); - }); - - it('logs in debug mode', () => { - mockIsDebugMode.mockReturnValue(true); - const props = { theme_preference: 'dark' as const }; - - setUserPropertiesWeb(props); - - // Implementation wraps properties in user_properties for structured logging - expect(mockLoggerDebug).toHaveBeenCalledWith( - 'setUserProperties', - expect.objectContaining({ - category: 'ANALYTICS', - user_properties: expect.objectContaining({ theme_preference: 'dark' }), - }) - ); - }); + __resetForTesting(); }); - describe('trackScreenViewWeb', () => { - beforeEach(async () => { - await initializeWebAnalytics({ - apiKey: 'test-key', - projectId: 'test-project', - appId: 'test-app-id', - measurementId: 'G-XXXXXXXX', - }); - jest.clearAllMocks(); - }); - - it('tracks screen view with name and class', () => { - trackScreenViewWeb('HomeScreen', 'TabScreen'); + describe('initializePlatformAnalytics', () => { + it('should initialize Amplitude with API key', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - expect(logEvent).toHaveBeenCalledWith(expect.anything(), 'screen_view', { - screen_name: 'HomeScreen', - screen_class: 'TabScreen', - }); + expect(amplitude.init).toHaveBeenCalledWith('test-key', undefined, expect.any(Object)); }); - it('uses screen name as class when class not provided', () => { - trackScreenViewWeb('HomeScreen'); + it('should not reinitialize if already initialized', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + await initializePlatformAnalytics({ apiKey: 'test-key' }); - expect(logEvent).toHaveBeenCalledWith(expect.anything(), 'screen_view', { - screen_name: 'HomeScreen', - screen_class: 'HomeScreen', - }); + expect(amplitude.init).toHaveBeenCalledTimes(1); }); - }); - describe('resetAnalyticsWeb', () => { - beforeEach(async () => { - await initializeWebAnalytics({ - apiKey: 'test-key', - projectId: 'test-project', - appId: 'test-app-id', - measurementId: 'G-XXXXXXXX', + it('should rethrow initialization errors to allow retry', async () => { + const mockError = new Error('Network error'); + (amplitude.init as jest.Mock).mockReturnValueOnce({ + promise: Promise.reject(mockError), }); - jest.clearAllMocks(); - }); - - it('clears user ID', async () => { - await resetAnalyticsWeb(); - - expect(setUserId).toHaveBeenCalledWith(expect.anything(), null); - }); - - it('logs in debug mode', async () => { - mockIsDebugMode.mockReturnValue(true); - await resetAnalyticsWeb(); - - expect(mockLoggerInfo).toHaveBeenCalledWith( - 'Resetting analytics state', - expect.objectContaining({ category: 'ANALYTICS' }) + await expect(initializePlatformAnalytics({ apiKey: 'test-key' })).rejects.toThrow( + 'Network error' ); - }); - }); -}); - -describe('Web Analytics - New Functionality', () => { - describe('Provider Configuration', () => { - const mockConfig = { - apiKey: 'test-key', - projectId: 'test-project', - appId: 'test-app-id', - measurementId: 'G-XXXXXXXX', - }; - - beforeEach(() => { - __resetForTesting(); - jest.clearAllMocks(); - (getApps as jest.Mock).mockReturnValue([]); - (isSupported as jest.Mock).mockResolvedValue(true); - mockIsDebugMode.mockReturnValue(false); - }); - it('initializes with Firebase provider by default', async () => { - await initializeWebAnalytics(mockConfig); - - expect(initializeApp).toHaveBeenCalled(); - expect(getAnalytics).toHaveBeenCalled(); - }); - - it('logs success message in debug mode', async () => { - mockIsDebugMode.mockReturnValue(true); - - await initializeWebAnalytics(mockConfig); - - expect(mockLoggerInfo).toHaveBeenCalledWith( - 'Firebase Analytics initialized for web', - expect.objectContaining({ - category: 'ANALYTICS', - }) - ); + // Verify init was called + expect(amplitude.init).toHaveBeenCalled(); }); }); - describe('Error Handling', () => { - const mockConfig = { - apiKey: 'test-key', - projectId: 'test-project', - appId: 'test-app-id', - measurementId: 'G-XXXXXXXX', - }; - - beforeEach(async () => { - __resetForTesting(); - jest.clearAllMocks(); - (getApps as jest.Mock).mockReturnValue([]); - (isSupported as jest.Mock).mockResolvedValue(true); - await initializeWebAnalytics(mockConfig); - jest.clearAllMocks(); - }); + describe('trackEventPlatform', () => { + it('should track event after initialization', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - it('handles logEvent errors gracefully', () => { - const error = new Error('Firebase error'); - (logEvent as jest.Mock).mockImplementationOnce(() => { - throw error; - }); + trackEventPlatform('Test Event', { param: 'value' }); - expect(() => trackEventWeb('test_event', { param1: 'value1' })).not.toThrow(); - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to track event test_event in Firebase', - error, - expect.objectContaining({ category: 'ANALYTICS' }) - ); + expect(amplitude.track).toHaveBeenCalledWith('Test Event', { param: 'value' }); }); - it('handles setUserId errors gracefully', () => { - const error = new Error('Set user ID failed'); - (setUserId as jest.Mock).mockImplementationOnce(() => { - throw error; - }); + it('should not track event before initialization', () => { + trackEventPlatform('Test Event', { param: 'value' }); - expect(() => setUserIdWeb('user-123')).not.toThrow(); - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to set user ID', - error, - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - - it('handles setUserProperties errors gracefully', () => { - const error = new Error('Set properties failed'); - (setUserProperties as jest.Mock).mockImplementationOnce(() => { - throw error; - }); - - expect(() => setUserPropertiesWeb({ theme_preference: 'dark' })).not.toThrow(); - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to set user properties', - error, - expect.objectContaining({ category: 'ANALYTICS' }) - ); - }); - - it('handles non-Error exceptions in logEvent', () => { - (logEvent as jest.Mock).mockImplementationOnce(() => { - throw 'string error'; - }); - - expect(() => trackEventWeb('test_event')).not.toThrow(); - expect(mockLoggerError).toHaveBeenCalledWith( - 'Failed to track event test_event in Firebase', - expect.any(Error), - expect.objectContaining({ category: 'ANALYTICS' }) - ); + expect(amplitude.track).not.toHaveBeenCalled(); }); }); - describe('Idempotency and Re-initialization', () => { - const mockConfig = { - apiKey: 'test-key', - projectId: 'test-project', - appId: 'test-app-id', - measurementId: 'G-XXXXXXXX', - }; - - beforeEach(() => { - __resetForTesting(); - jest.clearAllMocks(); - (getApps as jest.Mock).mockReturnValue([]); - (isSupported as jest.Mock).mockResolvedValue(true); - mockIsDebugMode.mockReturnValue(false); - }); - - it('prevents multiple initialization attempts', async () => { - await initializeWebAnalytics(mockConfig); - const firstCallCount = initializeApp.mock.calls.length; + describe('setUserIdPlatform', () => { + it('should set user ID after initialization', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - // Try to initialize again - await initializeWebAnalytics(mockConfig); + setUserIdPlatform('user-123'); - // Should not call initializeApp again - expect(initializeApp).toHaveBeenCalledTimes(firstCallCount); + expect(amplitude.setUserId).toHaveBeenCalledWith('user-123'); }); - it('logs debug message when skipping re-initialization', async () => { - mockIsDebugMode.mockReturnValue(true); - await initializeWebAnalytics(mockConfig); + it('should clear user ID when null', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - jest.clearAllMocks(); + setUserIdPlatform(null); - await initializeWebAnalytics(mockConfig); - - expect(mockLoggerDebug).toHaveBeenCalledWith( - 'Analytics already initialized, skipping re-initialization', - expect.objectContaining({ category: 'ANALYTICS' }) - ); + expect(amplitude.setUserId).toHaveBeenCalledWith(undefined); }); - it('allows re-initialization after __resetForTesting', async () => { - await initializeWebAnalytics(mockConfig); - - __resetForTesting(); - jest.clearAllMocks(); - (getApps as jest.Mock).mockReturnValue([]); + it('should not set user ID before initialization', () => { + setUserIdPlatform('user-123'); - await initializeWebAnalytics(mockConfig); - - // Should call initializeApp again after reset - expect(initializeApp).toHaveBeenCalledTimes(1); + expect(amplitude.setUserId).not.toHaveBeenCalled(); }); }); - describe('Analytics Not Initialized', () => { - beforeEach(() => { - __resetForTesting(); - jest.clearAllMocks(); - mockIsDebugMode.mockReturnValue(false); - }); - - it('logs debug message when tracking event without initialization', () => { - mockIsDebugMode.mockReturnValue(true); + describe('setUserPropertiesPlatform', () => { + it('should set user properties after initialization', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - trackEventWeb('test_event', { param1: 'value1' }); + setUserPropertiesPlatform({ has_sponsor: true, theme_preference: 'dark' }); - expect(mockLoggerDebug).toHaveBeenCalledWith( - expect.stringContaining('Event (not sent - no provider initialized)'), - expect.objectContaining({ - category: 'ANALYTICS', - event_params: expect.any(Object), - }) - ); + expect(amplitude.identify).toHaveBeenCalled(); }); - it('logs debug message when setting user ID without initialization', () => { - mockIsDebugMode.mockReturnValue(true); + it('should not set user properties before initialization', () => { + setUserPropertiesPlatform({ has_sponsor: true }); - setUserIdWeb('user-123'); - - // Wait for async hash operation - return new Promise((resolve) => { - setTimeout(() => { - expect(mockLoggerDebug).toHaveBeenCalledWith( - expect.stringContaining('setUserId (not sent - Firebase not initialized)'), - expect.objectContaining({ category: 'ANALYTICS' }) - ); - resolve(undefined); - }, 10); - }); + expect(amplitude.identify).not.toHaveBeenCalled(); }); - it('logs debug message when clearing user ID (null) without initialization', () => { - mockIsDebugMode.mockReturnValue(true); - - setUserIdWeb(null); - - // Wait for async hash operation (even for null, there's async processing) - return new Promise((resolve) => { - setTimeout(() => { - expect(mockLoggerDebug).toHaveBeenCalledWith( - 'setUserId (not sent - Firebase not initialized): null', - expect.objectContaining({ category: 'ANALYTICS' }) - ); - resolve(undefined); - }, 10); - }); - }); - - it('logs debug message when setting user properties without initialization', () => { - mockIsDebugMode.mockReturnValue(true); - - setUserPropertiesWeb({ theme_preference: 'dark' }); - - expect(mockLoggerDebug).toHaveBeenCalledWith( - 'setUserProperties (not sent - Firebase not initialized)', - expect.objectContaining({ - category: 'ANALYTICS', - user_properties: expect.any(Object), - }) - ); - }); + it('should skip undefined property values', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - it('does not call Firebase methods when not initialized', () => { - trackEventWeb('test_event'); - setUserIdWeb('user-123'); - setUserPropertiesWeb({ theme_preference: 'dark' }); + setUserPropertiesPlatform({ has_sponsor: true, theme_preference: undefined }); - expect(logEvent).not.toHaveBeenCalled(); - expect(setUserId).not.toHaveBeenCalled(); - expect(setUserProperties).not.toHaveBeenCalled(); + expect(amplitude.identify).toHaveBeenCalled(); }); }); - describe('User Properties Filtering', () => { - beforeEach(async () => { - __resetForTesting(); - jest.clearAllMocks(); - (getApps as jest.Mock).mockReturnValue([]); - (isSupported as jest.Mock).mockResolvedValue(true); - await initializeWebAnalytics({ - apiKey: 'test-key', - projectId: 'test-project', - appId: 'test-app-id', - measurementId: 'G-XXXXXXXX', - }); - jest.clearAllMocks(); - }); - - it('filters out undefined values from user properties', () => { - const props = { - theme_preference: 'dark' as const, - has_sponsor: undefined, - days_sober_bucket: '31-90' as const, - }; - - setUserPropertiesWeb(props); - - // Firebase should only receive defined values - expect(setUserProperties).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - theme_preference: 'dark', - days_sober_bucket: '31-90', - }) - ); - - // Should not include undefined values - const callArgs = (setUserProperties as jest.Mock).mock.calls[0][1]; - expect(callArgs).not.toHaveProperty('has_sponsor'); - }); - - it('preserves boolean false values', () => { - const props = { - has_sponsor: false, - has_sponsees: false, - }; - - setUserPropertiesWeb(props); - - expect(setUserProperties).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - has_sponsor: false, - has_sponsees: false, - }) - ); - }); - - it('handles empty properties object', () => { - setUserPropertiesWeb({}); - - expect(setUserProperties).toHaveBeenCalledWith(expect.anything(), {}); - }); - - it('handles all undefined properties', () => { - const props = { - theme_preference: undefined, - has_sponsor: undefined, - }; + describe('trackScreenViewPlatform', () => { + it('should track screen view event', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - setUserPropertiesWeb(props); + trackScreenViewPlatform('Home', 'TabScreen'); - expect(setUserProperties).toHaveBeenCalledWith(expect.anything(), {}); - }); - }); - - describe('Parameter Sanitization for Logging', () => { - beforeEach(async () => { - __resetForTesting(); - jest.clearAllMocks(); - (getApps as jest.Mock).mockReturnValue([]); - (isSupported as jest.Mock).mockResolvedValue(true); - mockIsDebugMode.mockReturnValue(true); - await initializeWebAnalytics({ - apiKey: 'test-key', - projectId: 'test-project', - appId: 'test-app-id', - measurementId: 'G-XXXXXXXX', - }); - jest.clearAllMocks(); - }); - - it('logs params without PII in debug mode', () => { - trackEventWeb('test_event', { - task_id: '123', - count: 5, - }); - - expect(mockLoggerDebug).toHaveBeenCalledWith( - 'Event: test_event', - expect.objectContaining({ - category: 'ANALYTICS', - event_params: expect.objectContaining({ - task_id: '123', - count: 5, - }), - }) - ); - }); - - it('redacts PII-prone keys from logged params', () => { - trackEventWeb('test_event', { - email: 'user@example.com', - task_id: '123', + expect(amplitude.track).toHaveBeenCalledWith('Screen Viewed', { + screen_name: 'Home', + screen_class: 'TabScreen', }); - - const debugCall = mockLoggerDebug.mock.calls.find((call) => - call[0].includes('Event: test_event') - ); - expect(debugCall).toBeDefined(); - expect(debugCall[1].event_params.email).toBe('[Filtered]'); - expect(debugCall[1].event_params.task_id).toBe('123'); }); - it('recursively sanitizes nested objects', () => { - trackEventWeb('test_event', { - metadata: { - email: 'user@example.com', - task_id: '123', - }, - }); + it('should use screen name as default screen class', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - const debugCall = mockLoggerDebug.mock.calls.find((call) => - call[0].includes('Event: test_event') - ); - expect(debugCall).toBeDefined(); - expect(debugCall[1].event_params.metadata.email).toBe('[Filtered]'); - expect(debugCall[1].event_params.metadata.task_id).toBe('123'); - }); + trackScreenViewPlatform('Settings'); - it('handles params with reserved logger keys', () => { - trackEventWeb('test_event', { - error_message: 'this should be filtered', - task_id: '123', + expect(amplitude.track).toHaveBeenCalledWith('Screen Viewed', { + screen_name: 'Settings', + screen_class: 'Settings', }); - - const debugCall = mockLoggerDebug.mock.calls.find((call) => - call[0].includes('Event: test_event') - ); - expect(debugCall).toBeDefined(); - // error_message should be filtered to prevent overwriting logger metadata - expect(debugCall[1].event_params).not.toHaveProperty('error_message'); - expect(debugCall[1].event_params.task_id).toBe('123'); }); }); - describe('__resetForTesting', () => { - it('throws error when called outside test environment', () => { - const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'production'; - - expect(() => __resetForTesting()).toThrow( - '__resetForTesting should only be called in test environments' - ); - - process.env.NODE_ENV = originalEnv; - }); - - it('successfully resets state in test environment', () => { - const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'test'; - - expect(() => __resetForTesting()).not.toThrow(); - - process.env.NODE_ENV = originalEnv; - }); - }); - - describe('Edge Cases', () => { - const mockConfig = { - apiKey: 'test-key', - projectId: 'test-project', - appId: 'test-app-id', - measurementId: 'G-XXXXXXXX', - }; - - beforeEach(async () => { - __resetForTesting(); - jest.clearAllMocks(); - (getApps as jest.Mock).mockReturnValue([]); - (isSupported as jest.Mock).mockResolvedValue(true); - await initializeWebAnalytics(mockConfig); - jest.clearAllMocks(); - }); - - it('handles trackEvent with null params', () => { - expect(() => trackEventWeb('test_event', null as any)).not.toThrow(); - expect(logEvent).toHaveBeenCalledWith(expect.anything(), 'test_event', null); - }); - - it('handles trackEvent with empty object params', () => { - trackEventWeb('test_event', {}); - expect(logEvent).toHaveBeenCalledWith(expect.anything(), 'test_event', {}); - }); - - it('handles empty screen name in trackScreenView', () => { - trackScreenViewWeb(''); - expect(logEvent).toHaveBeenCalledWith(expect.anything(), 'screen_view', { - screen_name: '', - screen_class: '', - }); - }); + describe('resetAnalyticsPlatform', () => { + it('should reset analytics state', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); - it('handles very long event names', () => { - const longName = 'a'.repeat(1000); - trackEventWeb(longName); - expect(logEvent).toHaveBeenCalledWith(expect.anything(), longName, undefined); - }); + await resetAnalyticsPlatform(); - it('handles special characters in event names', () => { - const specialName = 'event_with-special.chars!@#$%'; - trackEventWeb(specialName); - expect(logEvent).toHaveBeenCalledWith(expect.anything(), specialName, undefined); + expect(amplitude.reset).toHaveBeenCalled(); }); - it('handles params with circular references gracefully', () => { - const circular: any = { prop: 'value' }; - circular.self = circular; - - // This should not crash, even if logging might fail internally - expect(() => trackEventWeb('test_event', circular)).not.toThrow(); - }); - - it('correctly handles shared objects (not circular) in params', () => { - mockIsDebugMode.mockReturnValue(true); - - // Shared object used in multiple places - NOT circular, just shared - const shared = { id: '123', name: 'test' }; - const params = { - first: shared, - second: shared, - nested: { inner: shared }, - }; - - trackEventWeb('test_event', params); - - // Should log all instances of shared object correctly (not as [Circular]) - const debugCall = mockLoggerDebug.mock.calls.find((call) => - call[0].includes('Event: test_event') - ); - expect(debugCall).toBeDefined(); + it('should not reset before initialization', async () => { + await resetAnalyticsPlatform(); - // All references to the shared object should be sanitized properly, not marked [Circular] - expect(debugCall[1].event_params.first).toEqual({ id: '123', name: '[Filtered]' }); - expect(debugCall[1].event_params.second).toEqual({ id: '123', name: '[Filtered]' }); - expect(debugCall[1].event_params.nested.inner).toEqual({ id: '123', name: '[Filtered]' }); + expect(amplitude.reset).not.toHaveBeenCalled(); }); }); }); diff --git a/__tests__/plugins/withFirebaseConfig.test.ts b/__tests__/plugins/withFirebaseConfig.test.ts deleted file mode 100644 index a44b4050..00000000 --- a/__tests__/plugins/withFirebaseConfig.test.ts +++ /dev/null @@ -1,482 +0,0 @@ -/** - * @fileoverview Tests for withFirebaseConfig.js Expo config plugin - * - * Tests the Firebase configuration plugin including: - * - JSON and plist validation - * - Base64 decoding - * - File path handling (cross-platform) - * - Secret value processing - * - Fallback behavior - */ - -import fs from 'fs'; -import path from 'path'; - -// We need to test the internal functions, so we'll use require to access them -// The plugin exports only withFirebaseConfig, but we can test behavior through it - -describe('withFirebaseConfig plugin', () => { - // Store original env vars to restore after tests - const originalEnv = { ...process.env }; - - // Mock fs module - jest.mock('fs'); - const mockFs = fs as jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - process.env = { ...originalEnv }; - }); - - afterEach(() => { - process.env = { ...originalEnv }; - }); - - describe('isValidJson', () => { - // Since isValidJson is not exported, we test it indirectly through writeFromSecret behavior - // We'll create a separate test module to expose these functions for unit testing - - it('accepts valid JSON objects', () => { - const validJson = '{"api_key": "test123", "project_id": "my-project"}'; - expect(() => JSON.parse(validJson)).not.toThrow(); - }); - - it('accepts valid JSON with nested objects', () => { - const validJson = '{"client": [{"client_info": {"mobilesdk_app_id": "123"}}]}'; - expect(() => JSON.parse(validJson)).not.toThrow(); - }); - - it('rejects malformed JSON - missing closing brace', () => { - const invalidJson = '{"api_key": "test123"'; - expect(() => JSON.parse(invalidJson)).toThrow(); - }); - - it('rejects malformed JSON - trailing comma', () => { - const invalidJson = '{"api_key": "test123",}'; - expect(() => JSON.parse(invalidJson)).toThrow(); - }); - - it('rejects malformed JSON - unquoted keys', () => { - const invalidJson = '{api_key: "test123"}'; - expect(() => JSON.parse(invalidJson)).toThrow(); - }); - - it('rejects plain text', () => { - const plainText = 'not json at all'; - expect(() => JSON.parse(plainText)).toThrow(); - }); - }); - - describe('isValidPlist', () => { - it('accepts valid plist with XML declaration', () => { - const validPlist = ` - - - - API_KEY - test123 - -`; - - const trimmed = validPlist.trim(); - const hasXmlOrDoctype = trimmed.startsWith(''); - - expect(hasXmlOrDoctype).toBe(true); - expect(hasPlistOpen).toBe(true); - expect(hasPlistClose).toBe(true); - }); - - it('accepts valid plist with DOCTYPE only', () => { - const validPlist = ` - - -`; - - const trimmed = validPlist.trim(); - expect(trimmed.startsWith('')).toBe(true); - }); - - it('accepts valid plist starting directly with plist tag', () => { - const validPlist = ` - - API_KEY - test123 - -`; - - const trimmed = validPlist.trim(); - expect(trimmed.startsWith('')).toBe(true); - }); - - it('rejects plist missing closing tag', () => { - const invalidPlist = ` - - - API_KEY - test123 -`; - - expect(invalidPlist.includes('')).toBe(false); - }); - - it('rejects plist missing opening tag', () => { - const invalidPlist = ` - - API_KEY - test123 - -`; - - // Has closing but structurally invalid (no opening plist before content) - const trimmed = invalidPlist.trim(); - expect(trimmed.includes(' { - const notPlist = ` - -Not a plist -`; - - expect(notPlist.includes('')).toBe(false); - }); - - it('rejects HTML starting with <', () => { - const html = 'test'; - expect(html.includes(' { - it('detects JSON format', () => { - const json = '{"key": "value"}'; - // Valid JSON should be parseable - expect(() => JSON.parse(json)).not.toThrow(); - }); - - it('detects plist format', () => { - const plist = ``; - expect(plist.includes('')).toBe(true); - }); - - it('returns null for invalid content', () => { - const invalid = 'random text that is neither json nor plist'; - expect(() => JSON.parse(invalid)).toThrow(); - expect(invalid.includes(' { - it('correctly decodes base64-encoded JSON', () => { - const originalJson = '{"api_key": "test123"}'; - const base64 = Buffer.from(originalJson).toString('base64'); - const decoded = Buffer.from(base64, 'base64').toString('utf8'); - - expect(decoded).toBe(originalJson); - expect(() => JSON.parse(decoded)).not.toThrow(); - }); - - it('correctly decodes base64-encoded plist', () => { - const originalPlist = ``; - const base64 = Buffer.from(originalPlist).toString('base64'); - const decoded = Buffer.from(base64, 'base64').toString('utf8'); - - expect(decoded).toBe(originalPlist); - expect(decoded.includes(' { - const jsonWithWhitespace = ' {"api_key": "test123"}'; - const base64 = Buffer.from(jsonWithWhitespace).toString('base64'); - const decoded = Buffer.from(base64, 'base64').toString('utf8'); - - expect(decoded.trimStart().startsWith('{')).toBe(true); - expect(() => JSON.parse(decoded)).not.toThrow(); - }); - }); - - describe('path.isAbsolute cross-platform behavior', () => { - it('recognizes Unix absolute paths', () => { - expect(path.isAbsolute('/tmp/secret/file.json')).toBe(true); - expect(path.isAbsolute('/var/folders/abc/file.plist')).toBe(true); - }); - - it('recognizes relative paths as non-absolute', () => { - expect(path.isAbsolute('./google-services.json')).toBe(false); - expect(path.isAbsolute('../config/file.json')).toBe(false); - expect(path.isAbsolute('google-services.json')).toBe(false); - }); - - // Note: Windows path tests would need to run on Windows - // path.isAbsolute('C:\\temp\\file.json') returns true on Windows - }); - - describe('Sample Firebase config validation', () => { - it('validates realistic google-services.json structure', () => { - const googleServicesJson = JSON.stringify({ - project_info: { - project_number: '123456789', - project_id: 'my-app-id', - storage_bucket: 'my-app.appspot.com', - }, - client: [ - { - client_info: { - mobilesdk_app_id: '1:123456789:android:abc123', - android_client_info: { - package_name: 'com.example.app', - }, - }, - api_key: [ - { - current_key: 'AIzaSy...', - }, - ], - }, - ], - configuration_version: '1', - }); - - expect(() => JSON.parse(googleServicesJson)).not.toThrow(); - const parsed = JSON.parse(googleServicesJson); - expect(parsed.project_info).toBeDefined(); - expect(parsed.client).toBeInstanceOf(Array); - }); - - it('validates realistic GoogleService-Info.plist structure', () => { - const plist = ` - - - - API_KEY - AIzaSy... - GCM_SENDER_ID - 123456789 - PLIST_VERSION - 1 - BUNDLE_ID - com.example.app - PROJECT_ID - my-app-id - STORAGE_BUCKET - my-app.appspot.com - IS_ADS_ENABLED - - IS_ANALYTICS_ENABLED - - IS_APPINVITE_ENABLED - - IS_GCM_ENABLED - - IS_SIGNIN_ENABLED - - GOOGLE_APP_ID - 1:123456789:ios:abc123 - -`; - - const trimmed = plist.trim(); - expect(trimmed.startsWith('')).toBe(true); - expect(trimmed.includes('API_KEY')).toBe(true); - expect(trimmed.includes('PROJECT_ID')).toBe(true); - }); - }); - - describe('Edge cases', () => { - it('handles empty string secret value', () => { - const emptyString = ''; - expect(emptyString).toBeFalsy(); - }); - - it('handles null/undefined secret value', () => { - expect(null).toBeFalsy(); - expect(undefined).toBeFalsy(); - }); - - it('handles JSON with unicode characters', () => { - const unicodeJson = '{"name": "テスト", "emoji": "🔥"}'; - expect(() => JSON.parse(unicodeJson)).not.toThrow(); - const parsed = JSON.parse(unicodeJson); - expect(parsed.name).toBe('テスト'); - expect(parsed.emoji).toBe('🔥'); - }); - - it('handles base64 encoded content with unicode', () => { - const unicodeContent = '{"name": "日本語"}'; - const base64 = Buffer.from(unicodeContent).toString('base64'); - const decoded = Buffer.from(base64, 'base64').toString('utf8'); - expect(decoded).toBe(unicodeContent); - }); - - it('handles plist with special XML characters', () => { - const plistWithSpecialChars = ` - - - URL - https://example.com?a=1&b=2 - -`; - - expect(plistWithSpecialChars.includes(' { - // Firebase config should be an object, not an array - const jsonArray = '[{"key": "value"}]'; - const parsed = JSON.parse(jsonArray); - // This is valid JSON but not a valid Firebase config structure - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.project_info).toBeUndefined(); - }); - }); - - describe('Error handling scenarios', () => { - it('invalid base64 should not crash', () => { - // Invalid base64 string (not properly padded, contains invalid chars) - const invalidBase64 = '!!!not-valid-base64!!!'; - - // Buffer.from with 'base64' encoding is lenient and won't throw - // It will produce garbage output instead - expect(() => { - Buffer.from(invalidBase64, 'base64').toString('utf8'); - }).not.toThrow(); - }); - - it('truncated base64 should decode to truncated content', () => { - const original = '{"api_key": "test123"}'; - const fullBase64 = Buffer.from(original).toString('base64'); - // Truncate the base64 string - const truncated = fullBase64.substring(0, 10); - - const decoded = Buffer.from(truncated, 'base64').toString('utf8'); - // Should decode to something, but not valid JSON - expect(() => JSON.parse(decoded)).toThrow(); - }); - }); -}); - -describe('writeFromSecret behavior simulation', () => { - // These tests simulate the writeFromSecret function logic - // without actually calling it (since it's not exported) - - const simulateWriteFromSecret = (secretValue: string | null | undefined) => { - if (!secretValue) return { success: false, reason: 'empty' }; - - // Check if absolute path - if (path.isAbsolute(secretValue)) { - return { success: true, type: 'file_path', value: secretValue }; - } - - // Try to detect format - let isValidJsonContent = false; - try { - JSON.parse(secretValue); - isValidJsonContent = true; - } catch { - isValidJsonContent = false; - } - - const isValidPlistContent = secretValue.includes(''); - - if (isValidJsonContent || isValidPlistContent) { - return { - success: true, - type: 'raw_content', - format: isValidJsonContent ? 'json' : 'plist', - }; - } - - // Try base64 decode - try { - const decoded = Buffer.from(secretValue, 'base64').toString('utf8'); - - let decodedIsJson = false; - try { - JSON.parse(decoded); - decodedIsJson = true; - } catch { - decodedIsJson = false; - } - - const decodedIsPlist = decoded.includes(''); - - if (decodedIsJson || decodedIsPlist) { - return { - success: true, - type: 'base64_decoded', - format: decodedIsJson ? 'json' : 'plist', - decoded, - }; - } - } catch { - // Base64 decode failed - } - - // Fallback - write as-is with warning - return { success: true, type: 'fallback', warning: true }; - }; - - it('returns false for null/undefined/empty', () => { - expect(simulateWriteFromSecret(null).success).toBe(false); - expect(simulateWriteFromSecret(undefined).success).toBe(false); - expect(simulateWriteFromSecret('').success).toBe(false); - }); - - it('detects absolute file paths', () => { - const result = simulateWriteFromSecret('/tmp/secrets/google-services.json'); - expect(result.success).toBe(true); - expect(result.type).toBe('file_path'); - }); - - it('detects raw JSON content', () => { - const result = simulateWriteFromSecret('{"api_key": "test"}'); - expect(result.success).toBe(true); - expect(result.type).toBe('raw_content'); - expect(result.format).toBe('json'); - }); - - it('detects raw plist content', () => { - const plist = ''; - const result = simulateWriteFromSecret(plist); - expect(result.success).toBe(true); - expect(result.type).toBe('raw_content'); - expect(result.format).toBe('plist'); - }); - - it('decodes base64-encoded JSON', () => { - const json = '{"api_key": "test"}'; - const base64 = Buffer.from(json).toString('base64'); - const result = simulateWriteFromSecret(base64); - - expect(result.success).toBe(true); - expect(result.type).toBe('base64_decoded'); - expect(result.format).toBe('json'); - }); - - it('decodes base64-encoded plist', () => { - const plist = ''; - const base64 = Buffer.from(plist).toString('base64'); - const result = simulateWriteFromSecret(base64); - - expect(result.success).toBe(true); - expect(result.type).toBe('base64_decoded'); - expect(result.format).toBe('plist'); - }); - - it('falls back for unrecognized content', () => { - const result = simulateWriteFromSecret('random-gibberish-content'); - expect(result.success).toBe(true); - expect(result.type).toBe('fallback'); - expect(result.warning).toBe(true); - }); -}); diff --git a/__tests__/plugins/withModularHeaders.test.ts b/__tests__/plugins/withModularHeaders.test.ts deleted file mode 100644 index d00af3b9..00000000 --- a/__tests__/plugins/withModularHeaders.test.ts +++ /dev/null @@ -1,521 +0,0 @@ -/** - * @fileoverview Tests for withModularHeaders.js Expo config plugin - * - * Tests the modular headers plugin including: - * - Podfile modification behavior - * - Idempotency (not adding duplicate entries) - * - Platform line detection and regex matching - * - Error handling for invalid configs - */ - -// Mock expo/config-plugins before importing -import { withPodfile } from 'expo/config-plugins'; - -jest.mock('expo/config-plugins', () => ({ - withPodfile: jest.fn((config, callback) => { - // Simulate what withPodfile does - call the callback with config - return callback(config); - }), -})); - -// Import the plugin after mocking -// eslint-disable-next-line @typescript-eslint/no-require-imports -const withModularHeaders = require('../../plugins/withModularHeaders'); - -describe('withModularHeaders plugin', () => { - const mockWithPodfile = withPodfile as jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('Podfile modification', () => { - it('adds use_modular_headers! after platform declaration', () => { - const podfileContents = `platform :ios, '13.4' - -target 'MyApp' do - use_react_native! -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - - expect(result.modResults.contents).toContain('use_modular_headers!'); - expect(result.modResults.contents).toContain( - '# Enable modular headers globally for Firebase Swift compatibility' - ); - }); - - it('places use_modular_headers! immediately after platform line', () => { - const podfileContents = `platform :ios, '13.4' - -target 'MyApp' do - use_react_native! -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - - // Verify the order: platform line, then comment, then use_modular_headers! - const lines = result.modResults.contents.split('\n'); - const platformIndex = lines.findIndex((line: string) => line.includes('platform :ios')); - const commentIndex = lines.findIndex((line: string) => - line.includes('# Enable modular headers') - ); - const modularHeadersIndex = lines.findIndex((line: string) => - line.includes('use_modular_headers!') - ); - - expect(platformIndex).toBeLessThan(commentIndex); - expect(commentIndex).toBeLessThan(modularHeadersIndex); - expect(modularHeadersIndex).toBe(commentIndex + 1); - }); - - it('handles platform line with different iOS versions', () => { - const versions = ["'12.0'", "'13.0'", "'14.0'", "'15.0'", "'13.4'"]; - - versions.forEach((version) => { - const podfileContents = `platform :ios, ${version} - -target 'MyApp' do -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - mockWithPodfile.mockImplementationOnce((cfg, callback) => callback(cfg)); - const result = withModularHeaders(config); - - expect(result.modResults.contents).toContain('use_modular_headers!'); - }); - }); - - it('handles platform line without version', () => { - const podfileContents = `platform :ios - -target 'MyApp' do -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - - expect(result.modResults.contents).toContain('use_modular_headers!'); - }); - }); - - describe('Idempotency', () => { - it('does not add use_modular_headers! if already present', () => { - const podfileContents = `platform :ios, '13.4' - -# Enable modular headers globally for Firebase Swift compatibility -use_modular_headers! - -target 'MyApp' do - use_react_native! -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - - // Count occurrences of use_modular_headers! - const matches = result.modResults.contents.match(/use_modular_headers!/g); - expect(matches).toHaveLength(1); - }); - - it('detects use_modular_headers! anywhere in file', () => { - const podfileContents = `platform :ios, '13.4' - -target 'MyApp' do - use_modular_headers! - use_react_native! -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - - // Should not modify if already present (even in wrong location) - const matches = result.modResults.contents.match(/use_modular_headers!/g); - expect(matches).toHaveLength(1); - }); - - it('running plugin twice produces same result', () => { - const podfileContents = `platform :ios, '13.4' - -target 'MyApp' do -end`; - - const config1 = { - modResults: { - contents: podfileContents, - }, - }; - - // First run - const result1 = withModularHeaders(config1); - const afterFirstRun = result1.modResults.contents; - - // Second run with result from first - const config2 = { - modResults: { - contents: afterFirstRun, - }, - }; - - mockWithPodfile.mockImplementationOnce((cfg, callback) => callback(cfg)); - const result2 = withModularHeaders(config2); - - expect(result2.modResults.contents).toBe(afterFirstRun); - }); - }); - - describe('Platform line regex matching', () => { - it('matches platform line at start of file', () => { - const podfileContents = `platform :ios, '13.4' -target 'MyApp' do -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - expect(result.modResults.contents).toContain('use_modular_headers!'); - }); - - it('matches platform line with leading whitespace', () => { - const podfileContents = ` platform :ios, '13.4' - -target 'MyApp' do -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - expect(result.modResults.contents).toContain('use_modular_headers!'); - }); - - it('matches platform line with tab indentation', () => { - const podfileContents = `\tplatform :ios, '13.4' - -target 'MyApp' do -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - expect(result.modResults.contents).toContain('use_modular_headers!'); - }); - - it('does not modify if no platform line found', () => { - const podfileContents = `target 'MyApp' do - use_react_native! -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - - // Should not add use_modular_headers! if no platform line - expect(result.modResults.contents).not.toContain('use_modular_headers!'); - expect(result.modResults.contents).toBe(podfileContents); - }); - }); - - describe('Error handling', () => { - it('throws error if modResults is undefined', () => { - const config = {}; - - expect(() => withModularHeaders(config)).toThrow( - 'Invalid Podfile config: modResults.contents must be a string' - ); - }); - - it('throws error if modResults.contents is undefined', () => { - const config = { - modResults: {}, - }; - - expect(() => withModularHeaders(config)).toThrow( - 'Invalid Podfile config: modResults.contents must be a string' - ); - }); - - it('throws error if modResults.contents is not a string', () => { - const config = { - modResults: { - contents: 12345, - }, - }; - - expect(() => withModularHeaders(config)).toThrow( - 'Invalid Podfile config: modResults.contents must be a string' - ); - }); - - it('throws error if modResults.contents is null', () => { - const config = { - modResults: { - contents: null, - }, - }; - - expect(() => withModularHeaders(config)).toThrow( - 'Invalid Podfile config: modResults.contents must be a string' - ); - }); - }); - - describe('Realistic Podfile scenarios', () => { - it('handles typical Expo-generated Podfile', () => { - const podfileContents = `require File.join(File.dirname(\`node --print "require.resolve('expo/package.json')"\`), "scripts/autolinking") -require File.join(File.dirname(\`node --print "require.resolve('react-native/package.json')"\`), "scripts/react_native_pods") - -require 'json' -podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {} - -ENV['RCT_NEW_ARCH_ENABLED'] = podfile_properties['newArchEnabled'] == 'true' ? '1' : '0' -ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR'] - -platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1' - -install! 'cocoapods', :deterministic_uuids => false - -target 'SobrietyWaypoint' do - use_expo_modules! - config = use_native_modules! - - use_react_native!( - :path => config[:reactNativePath], - :hermes_enabled => true, - :app_path => "#{Pod::Config.instance.installation_root}/.." - ) - - post_install do |installer| - react_native_post_install(installer) - end -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - - expect(result.modResults.contents).toContain('use_modular_headers!'); - expect(result.modResults.contents).toContain( - '# Enable modular headers globally for Firebase Swift compatibility' - ); - - // Verify it's placed after platform line - const platformIndex = result.modResults.contents.indexOf('platform :ios'); - const modularIndex = result.modResults.contents.indexOf('use_modular_headers!'); - expect(modularIndex).toBeGreaterThan(platformIndex); - - // Verify it's before targets - const targetIndex = result.modResults.contents.indexOf("target 'SobrietyWaypoint'"); - expect(modularIndex).toBeLessThan(targetIndex); - }); - - it('handles Podfile with existing Firebase pods', () => { - const podfileContents = `platform :ios, '13.4' - -target 'MyApp' do - pod 'Firebase/Analytics' - pod 'Firebase/Crashlytics' - use_react_native! -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - - expect(result.modResults.contents).toContain('use_modular_headers!'); - // Firebase pods should still be there - expect(result.modResults.contents).toContain("pod 'Firebase/Analytics'"); - expect(result.modResults.contents).toContain("pod 'Firebase/Crashlytics'"); - }); - - it('preserves existing comments and formatting', () => { - const podfileContents = `# This is a comment at the top -platform :ios, '13.4' - -# Another comment -target 'MyApp' do - # Pod comment - pod 'SomePod' -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - - // Original comments should be preserved - expect(result.modResults.contents).toContain('# This is a comment at the top'); - expect(result.modResults.contents).toContain('# Another comment'); - expect(result.modResults.contents).toContain('# Pod comment'); - expect(result.modResults.contents).toContain('use_modular_headers!'); - }); - }); - - describe('Edge cases', () => { - it('handles empty Podfile', () => { - const config = { - modResults: { - contents: '', - }, - }; - - const result = withModularHeaders(config); - - // No platform line, so no modification - expect(result.modResults.contents).toBe(''); - }); - - it('handles Podfile with only whitespace', () => { - const config = { - modResults: { - contents: ' \n\n \t\t\n', - }, - }; - - const result = withModularHeaders(config); - - // No platform line, so no modification - expect(result.modResults.contents).not.toContain('use_modular_headers!'); - }); - - it('handles platform line as last line without newline', () => { - const podfileContents = `platform :ios, '13.4'`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - - expect(result.modResults.contents).toContain('use_modular_headers!'); - }); - - it('does not match platform :android or other platforms', () => { - const podfileContents = `platform :android, '21' - -target 'MyApp' do -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - - // Should not add use_modular_headers! for non-iOS platform - expect(result.modResults.contents).not.toContain('use_modular_headers!'); - }); - - it('handles multiple platform lines (uses first match)', () => { - const podfileContents = `platform :ios, '13.4' -platform :ios, '14.0' - -target 'MyApp' do -end`; - - const config = { - modResults: { - contents: podfileContents, - }, - }; - - const result = withModularHeaders(config); - - // Should add after first platform line only - const matches = result.modResults.contents.match(/use_modular_headers!/g); - expect(matches).toHaveLength(1); - }); - }); - - describe('withPodfile integration', () => { - it('calls withPodfile with correct arguments', () => { - const config = { - modResults: { - contents: `platform :ios, '13.4'`, - }, - }; - - withModularHeaders(config); - - expect(mockWithPodfile).toHaveBeenCalledTimes(1); - expect(mockWithPodfile).toHaveBeenCalledWith(config, expect.any(Function)); - }); - - it('returns the modified config from withPodfile', () => { - const config = { - name: 'TestApp', - modResults: { - contents: `platform :ios, '13.4'`, - }, - }; - - const result = withModularHeaders(config); - - // Should return config with modified contents - expect(result.name).toBe('TestApp'); - expect(result.modResults.contents).toContain('use_modular_headers!'); - }); - }); -}); diff --git a/__tests__/types/analytics.test.ts b/__tests__/types/analytics.test.ts index 8a1f6ffa..0e33d7a3 100644 --- a/__tests__/types/analytics.test.ts +++ b/__tests__/types/analytics.test.ts @@ -1,64 +1,138 @@ // __tests__/types/analytics.test.ts -import type { - EventParams, - UserProperties, - AnalyticsConfig, - DaysSoberBucket, +import { + AnalyticsEvents, + type AnalyticsEventName, + type EventParams, + type UserProperties, + type DaysSoberBucket, + type StepsCompletedBucket, + type AnalyticsConfig, } from '@/types/analytics'; -describe('Analytics Types', () => { - describe('EventParams', () => { - it('accepts valid event parameters', () => { +describe('types/analytics', () => { + describe('AnalyticsEvents', () => { + it('should have all required event constants', () => { + // Auth events + expect(AnalyticsEvents.AUTH_SIGN_UP).toBe('Auth Sign Up'); + expect(AnalyticsEvents.AUTH_LOGIN).toBe('Auth Login'); + expect(AnalyticsEvents.AUTH_LOGOUT).toBe('Auth Logout'); + + // Onboarding events + expect(AnalyticsEvents.ONBOARDING_STARTED).toBe('Onboarding Started'); + expect(AnalyticsEvents.ONBOARDING_STEP_COMPLETED).toBe('Onboarding Step Completed'); + expect(AnalyticsEvents.ONBOARDING_SOBRIETY_DATE_SET).toBe('Onboarding Sobriety Date Set'); + expect(AnalyticsEvents.ONBOARDING_COMPLETED).toBe('Onboarding Completed'); + expect(AnalyticsEvents.ONBOARDING_SCREEN_VIEWED).toBe('Onboarding Screen Viewed'); + expect(AnalyticsEvents.ONBOARDING_FIELD_COMPLETED).toBe('Onboarding Field Completed'); + expect(AnalyticsEvents.ONBOARDING_ABANDONED).toBe('Onboarding Abandoned'); + + // Core Features events + expect(AnalyticsEvents.SCREEN_VIEWED).toBe('Screen Viewed'); + expect(AnalyticsEvents.TASK_VIEWED).toBe('Task Viewed'); + expect(AnalyticsEvents.TASK_STARTED).toBe('Task Started'); + expect(AnalyticsEvents.TASK_COMPLETED).toBe('Task Completed'); + expect(AnalyticsEvents.TASK_CREATED).toBe('Task Created'); + expect(AnalyticsEvents.TASK_SKIPPED).toBe('Task Skipped'); + expect(AnalyticsEvents.TASK_STREAK_UPDATED).toBe('Task Streak Updated'); + + // Step events + expect(AnalyticsEvents.STEP_VIEWED).toBe('Step Viewed'); + expect(AnalyticsEvents.STEP_STARTED).toBe('Step Started'); + expect(AnalyticsEvents.STEP_PROGRESS_SAVED).toBe('Step Progress Saved'); + expect(AnalyticsEvents.STEP_COMPLETED).toBe('Step Completed'); + + // Milestone events + expect(AnalyticsEvents.MILESTONE_REACHED).toBe('Milestone Reached'); + expect(AnalyticsEvents.MILESTONE_SHARED).toBe('Milestone Shared'); + expect(AnalyticsEvents.MILESTONE_CELEBRATED).toBe('Milestone Celebrated'); + + // Social events + expect(AnalyticsEvents.SPONSOR_CONNECTED).toBe('Sponsor Connected'); + expect(AnalyticsEvents.SPONSOR_INVITE_SENT).toBe('Sponsor Invite Sent'); + expect(AnalyticsEvents.SPONSOR_INVITE_ACCEPTED).toBe('Sponsor Invite Accepted'); + expect(AnalyticsEvents.SPONSEE_ADDED).toBe('Sponsee Added'); + expect(AnalyticsEvents.MESSAGE_SENT).toBe('Message Sent'); + expect(AnalyticsEvents.MESSAGE_READ).toBe('Message Read'); + + // Engagement events + expect(AnalyticsEvents.APP_OPENED).toBe('App Opened'); + expect(AnalyticsEvents.APP_BACKGROUNDED).toBe('App Backgrounded'); + expect(AnalyticsEvents.APP_SESSION_STARTED).toBe('App Session Started'); + expect(AnalyticsEvents.DAILY_CHECK_IN).toBe('Daily Check In'); + + // Settings events + expect(AnalyticsEvents.SETTINGS_CHANGED).toBe('Settings Changed'); + + // Savings events + expect(AnalyticsEvents.SAVINGS_GOAL_SET).toBe('Savings Goal Set'); + expect(AnalyticsEvents.SAVINGS_UPDATED).toBe('Savings Updated'); + }); + + it('should use Title Case naming convention', () => { + const eventValues = Object.values(AnalyticsEvents); + eventValues.forEach((value) => { + // Title Case: first letter of each word is uppercase + const words = value.split(' '); + words.forEach((word) => { + expect(word[0]).toBe(word[0].toUpperCase()); + }); + }); + }); + + it('should have a reasonable number of events', () => { + const eventCount = Object.keys(AnalyticsEvents).length; + // Sanity check that events are defined - exact count is intentionally not tested + // to avoid brittle tests that break when events are legitimately added/removed + expect(eventCount).toBeGreaterThan(0); + }); + }); + + describe('Type definitions', () => { + it('should allow valid EventParams', () => { const params: EventParams = { task_id: '123', - days_to_complete: 3, - is_first_task: true, - optional_field: undefined, + count: 5, + is_first: true, + tags: ['tag1', 'tag2'], }; - expect(params.task_id).toBe('123'); - expect(params.days_to_complete).toBe(3); - expect(params.is_first_task).toBe(true); + expect(params).toBeDefined(); }); - }); - describe('UserProperties', () => { - it('accepts valid user properties', () => { + it('should allow valid UserProperties', () => { const props: UserProperties = { days_sober_bucket: '31-90', has_sponsor: true, has_sponsees: false, theme_preference: 'dark', sign_in_method: 'google', + onboarding_completed: true, + task_streak_current: 7, + steps_completed_count: '4-6', + savings_goal_set: true, }; - expect(props.days_sober_bucket).toBe('31-90'); - expect(props.sign_in_method).toBe('google'); + expect(props).toBeDefined(); }); - it('allows partial user properties', () => { - const props: UserProperties = { - theme_preference: 'system', - }; - expect(props.theme_preference).toBe('system'); - expect(props.has_sponsor).toBeUndefined(); - }); - }); - - describe('DaysSoberBucket', () => { - it('defines all expected bucket ranges', () => { + it('should allow valid DaysSoberBucket values', () => { const buckets: DaysSoberBucket[] = ['0-7', '8-30', '31-90', '91-180', '181-365', '365+']; expect(buckets).toHaveLength(6); }); - }); - describe('AnalyticsConfig', () => { - it('accepts web configuration', () => { + it('should allow valid StepsCompletedBucket values', () => { + const buckets: StepsCompletedBucket[] = ['0', '1-3', '4-6', '7-9', '10-12']; + expect(buckets).toHaveLength(5); + }); + + it('should allow valid AnalyticsConfig', () => { const config: AnalyticsConfig = { apiKey: 'test-api-key', - projectId: 'test-project', - appId: 'test-app-id', - measurementId: 'G-XXXXXXXX', }; - expect(config.measurementId).toBe('G-XXXXXXXX'); + expect(config.apiKey).toBe('test-api-key'); + }); + + it('should derive AnalyticsEventName from AnalyticsEvents values', () => { + const eventName: AnalyticsEventName = 'Auth Sign Up'; + expect(eventName).toBe(AnalyticsEvents.AUTH_SIGN_UP); }); }); }); diff --git a/app.config.ts b/app.config.ts index 3f72628d..dfd639c5 100644 --- a/app.config.ts +++ b/app.config.ts @@ -7,28 +7,8 @@ import { ConfigContext, ExpoConfig } from 'expo/config'; * This configuration includes EAS Update settings for over-the-air updates. * The runtime version uses the SDK version policy for managed workflow compatibility. * - * ## Firebase Configuration Strategy - * - * Firebase config files are loaded using a two-tier approach: - * - * 1. **EAS Builds (Production/Preview)**: Environment variables provide paths to Firebase config files. - * - `GOOGLE_SERVICES_JSON` - Path to Android's google-services.json (set via EAS file secrets) - * - `GOOGLE_SERVICE_INFO_PLIST` - Path to iOS's GoogleService-Info.plist (set via EAS file secrets) - * - * 2. **Local Development**: Falls back to local files in project root when env vars are not set. - * - `./google-services.json` - Android config (gitignored, must be added manually) - * - `./GoogleService-Info.plist` - iOS config (gitignored, must be added manually) - * - * To set up EAS secrets: - * ```bash - * eas secret:create --scope project --name GOOGLE_SERVICES_JSON --type file --value ./google-services.json - * eas secret:create --scope project --name GOOGLE_SERVICE_INFO_PLIST --type file --value ./GoogleService-Info.plist - * ``` - * * @see {@link https://docs.expo.dev/eas-update/getting-started/ EAS Update Documentation} * @see {@link https://docs.expo.dev/distribution/runtime-versions/ Runtime Version Documentation} - * @see {@link https://docs.expo.dev/build-reference/variables/#using-secrets-in-environment-variables EAS Secrets} - * @see {@link https://rnfirebase.io/ React Native Firebase} */ export default ({ config }: ConfigContext): ExpoConfig => ({ ...config, @@ -77,13 +57,6 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ icon: './assets/images/logo.png', supportsTablet: true, usesAppleSignIn: true, // Enable Sign in with Apple capability - /** - * Firebase iOS configuration file path. - * Uses GOOGLE_SERVICE_INFO_PLIST env var (set by EAS file secrets) or falls back - * to local file for development. The file contains Firebase project credentials - * including API keys, project ID, and GCM sender ID. - */ - googleServicesFile: process.env.GOOGLE_SERVICE_INFO_PLIST || './GoogleService-Info.plist', infoPlist: { ITSAppUsesNonExemptEncryption: false, }, @@ -94,13 +67,6 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ android: { package: 'com.volvox.sobers', icon: './assets/images/logo.png', - /** - * Firebase Android configuration file path. - * Uses GOOGLE_SERVICES_JSON env var (set by EAS file secrets) or falls back - * to local file for development. The file contains Firebase project credentials - * including API keys, project ID, and client information. - */ - googleServicesFile: process.env.GOOGLE_SERVICES_JSON || './google-services.json', adaptiveIcon: { backgroundColor: '#E6F4FE', foregroundImage: './assets/images/logo.png', @@ -128,16 +94,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'expo-font', 'expo-secure-store', 'expo-web-browser', - [ - 'expo-build-properties', - { - ios: { - // Note: Firebase requires modular headers, configured via the withModularHeaders plugin - }, - }, - ], - './plugins/withModularHeaders', // Required for Firebase/GoogleUtilities Swift compatibility - // Note: Firebase config files are handled via googleServicesFile config + EAS secrets + 'expo-build-properties', [ 'expo-splash-screen', { @@ -158,7 +115,6 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ organization: 'volvox', }, ], - '@react-native-firebase/app', // Edge-to-edge with Material 3 theme for native Android bottom tabs // This replaces the standalone react-native-bottom-tabs plugin when edgeToEdgeEnabled is true ['react-native-edge-to-edge', { android: { parentTheme: 'Material3' } }], diff --git a/app/_layout.tsx b/app/_layout.tsx index 3dec31b1..0741ed7f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -7,8 +7,8 @@ import { logger, LogCategory } from '@/lib/logger'; initializeSentry(); /* eslint-disable import/first -- Sentry and Analytics must initialize before React components load */ -// Initialize Firebase Analytics for event tracking (skip during SSR) -// Dynamic import prevents build failures - Firebase SDK requires browser APIs +// Initialize Amplitude Analytics for event tracking (skip during SSR) +// Dynamic import prevents build failures - analytics SDK requires browser APIs // Note: Async init means early events may be dropped (handled gracefully by analytics module) if (typeof window !== 'undefined') { import('@/lib/analytics') @@ -34,7 +34,7 @@ import { AuthProvider } from '@/contexts/AuthContext'; import { ThemeProvider, useTheme } from '@/contexts/ThemeContext'; import { DevToolsProvider } from '@/contexts/DevToolsContext'; import { ErrorBoundary } from '@/components/ErrorBoundary'; -import { StyleSheet, Platform } from 'react-native'; +import { StyleSheet, Platform, AppState, type AppStateStatus } from 'react-native'; import { KeyboardProvider } from 'react-native-keyboard-controller'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; @@ -47,7 +47,8 @@ import { JetBrainsMono_600SemiBold, JetBrainsMono_700Bold, } from '@expo-google-fonts/jetbrains-mono'; -import { trackScreenView } from '@/lib/analytics'; +import { trackScreenView, trackEvent, AnalyticsEvents } from '@/lib/analytics'; +import AsyncStorage from '@react-native-async-storage/async-storage'; /* eslint-enable import/first */ // Prevent splash screen from auto-hiding @@ -79,6 +80,8 @@ function RootLayoutNav(): React.ReactElement { const navigationRef = useNavigationContainerRef(); const pathname = usePathname(); const previousPathname = useRef(null); + const appStateRef = useRef(AppState.currentState); + const sessionStartTimeRef = useRef(Date.now()); // Register navigation container with Sentry useEffect(() => { @@ -87,6 +90,62 @@ function RootLayoutNav(): React.ReactElement { } }, [navigationRef]); + // Track app opened and daily check-in on mount + useEffect(() => { + const trackAppOpened = async () => { + // Track app opened + trackEvent(AnalyticsEvents.APP_OPENED, { + timestamp: new Date().toISOString(), + }); + + // Track session started + trackEvent(AnalyticsEvents.APP_SESSION_STARTED, { + timestamp: new Date().toISOString(), + }); + + // Check for daily check-in (first open of the day) + try { + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const lastCheckIn = await AsyncStorage.getItem('last_daily_check_in'); + + if (lastCheckIn !== today) { + trackEvent(AnalyticsEvents.DAILY_CHECK_IN, { + date: today, + is_first_today: true, + }); + await AsyncStorage.setItem('last_daily_check_in', today); + } + } catch { + // Silently fail - analytics is non-critical + } + }; + + trackAppOpened(); + }, []); + + // Track app state changes (foreground/background) + useEffect(() => { + const handleAppStateChange = (nextAppState: AppStateStatus) => { + if (appStateRef.current === 'active' && nextAppState.match(/inactive|background/)) { + // App is going to background + const sessionDuration = Math.floor((Date.now() - sessionStartTimeRef.current) / 1000); + trackEvent(AnalyticsEvents.APP_BACKGROUNDED, { + session_duration_seconds: sessionDuration, + }); + } else if (appStateRef.current.match(/inactive|background/) && nextAppState === 'active') { + // App is coming to foreground - start new session + sessionStartTimeRef.current = Date.now(); + trackEvent(AnalyticsEvents.APP_SESSION_STARTED, { + timestamp: new Date().toISOString(), + }); + } + appStateRef.current = nextAppState; + }; + + const subscription = AppState.addEventListener('change', handleAppStateChange); + return () => subscription.remove(); + }, []); + // Track screen views on navigation useEffect(() => { if (pathname && pathname !== previousPathname.current) { diff --git a/app/onboarding.tsx b/app/onboarding.tsx index 7c315847..4ae9bf9a 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect, useCallback } from 'react'; +import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import { View, Text, @@ -109,6 +109,11 @@ export default function OnboardingScreen() { const [spendingFrequency, setSpendingFrequency] = useState('weekly'); const [spendingError, setSpendingError] = useState(null); + // Refs to track which fields have been completed (to avoid duplicate analytics events) + const hasTrackedDisplayName = useRef(false); + const hasTrackedSobrietyDate = useRef(false); + const hasTrackedSavingsEnabled = useRef(false); + // Refresh profile on mount to catch any pending updates (e.g., Apple Sign In) // Apple Sign In updates the profile AFTER navigation to onboarding happens, // so we need to re-fetch to get the display_name that was just set. @@ -163,9 +168,12 @@ export default function OnboardingScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [awaitingProfileUpdate, profile]); - // Track onboarding started on mount + // Track onboarding started and screen viewed on mount useEffect(() => { trackEvent(AnalyticsEvents.ONBOARDING_STARTED); + trackEvent(AnalyticsEvents.ONBOARDING_SCREEN_VIEWED, { + screen_name: 'profile_setup', + }); }, []); // Debounced validation for display name @@ -173,6 +181,14 @@ export default function OnboardingScreen() { const timeoutId = setTimeout(() => { const error = validateDisplayName(displayName); setDisplayNameError(error); + + // Track field completion when display name becomes valid for the first time + if (!error && displayName.trim() && !hasTrackedDisplayName.current) { + hasTrackedDisplayName.current = true; + trackEvent(AnalyticsEvents.ONBOARDING_FIELD_COMPLETED, { + field_name: 'display_name', + }); + } }, VALIDATION_DEBOUNCE_MS); return () => clearTimeout(timeoutId); @@ -184,6 +200,15 @@ export default function OnboardingScreen() { setSpendingError(null); return; } + + // Track field completion when savings tracking is enabled for the first time + if (!hasTrackedSavingsEnabled.current) { + hasTrackedSavingsEnabled.current = true; + trackEvent(AnalyticsEvents.ONBOARDING_FIELD_COMPLETED, { + field_name: 'savings_tracking', + }); + } + if (!spendingAmount.trim()) { setSpendingError('Amount is required when tracking is enabled'); return; @@ -227,6 +252,14 @@ export default function OnboardingScreen() { const handleSignOut = async () => { try { + // Track onboarding abandonment before signing out + const durationSeconds = Math.floor((Date.now() - onboardingStartTime) / 1000); + trackEvent(AnalyticsEvents.ONBOARDING_ABANDONED, { + duration_seconds: durationSeconds, + had_display_name: displayName.trim().length > 0, + had_savings_enabled: isSavingsEnabled, + }); + await signOut(); router.replace('/login'); } catch (error) { @@ -283,10 +316,16 @@ export default function OnboardingScreen() { if (error) throw error; - // Track onboarding completion with duration + // Track step and onboarding completion with duration const durationSeconds = Math.floor((Date.now() - onboardingStartTime) / 1000); + trackEvent(AnalyticsEvents.ONBOARDING_STEP_COMPLETED, { + step_name: 'profile_setup', + step_number: 1, + total_steps: 1, + }); trackEvent(AnalyticsEvents.ONBOARDING_COMPLETED, { duration_seconds: durationSeconds, + savings_enabled: isSavingsEnabled, }); // Refresh the profile state in AuthContext @@ -327,6 +366,14 @@ export default function OnboardingScreen() { trackEvent(AnalyticsEvents.ONBOARDING_SOBRIETY_DATE_SET, { days_sober_bucket: calculateDaysSoberBucket(daysSober), }); + + // Track field completion the first time sobriety date is explicitly set + if (!hasTrackedSobrietyDate.current) { + hasTrackedSobrietyDate.current = true; + trackEvent(AnalyticsEvents.ONBOARDING_FIELD_COMPLETED, { + field_name: 'sobriety_date', + }); + } } }; diff --git a/components/settings/SettingsContent.tsx b/components/settings/SettingsContent.tsx index 809d7e0b..437bc5fb 100644 --- a/components/settings/SettingsContent.tsx +++ b/components/settings/SettingsContent.tsx @@ -52,7 +52,7 @@ import * as Clipboard from 'expo-clipboard'; import { logger, LogCategory } from '@/lib/logger'; import { supabase } from '@/lib/supabase'; import { captureSentryException } from '@/lib/sentry'; -import { trackEvent } from '@/lib/analytics'; +import { trackEvent, AnalyticsEvents } from '@/lib/analytics'; import { validateDisplayName } from '@/lib/validation'; import { hexWithAlpha } from '@/lib/format'; import { showConfirm } from '@/lib/alert'; @@ -694,6 +694,13 @@ export function SettingsContent({ onDismiss }: SettingsContentProps) { if (error) throw error; await refreshProfile(); + + // Track settings change + trackEvent(AnalyticsEvents.SETTINGS_CHANGED, { + setting: 'show_savings_card', + value: !newValue, // newValue is hide_savings_card, so invert for show + }); + showToast.success(newValue ? 'Savings card hidden' : 'Savings card visible'); } catch (error) { const err = error instanceof Error ? error : new Error('Failed to update setting'); @@ -754,7 +761,13 @@ export function SettingsContent({ onDismiss }: SettingsContentProps) { setThemeMode('light')} + onPress={() => { + setThemeMode('light'); + trackEvent(AnalyticsEvents.SETTINGS_CHANGED, { + setting: 'theme', + value: 'light', + }); + }} accessibilityRole="radio" accessibilityState={{ checked: themeMode === 'light' }} accessibilityLabel="Light theme" @@ -776,7 +789,13 @@ export function SettingsContent({ onDismiss }: SettingsContentProps) { setThemeMode('dark')} + onPress={() => { + setThemeMode('dark'); + trackEvent(AnalyticsEvents.SETTINGS_CHANGED, { + setting: 'theme', + value: 'dark', + }); + }} accessibilityRole="radio" accessibilityState={{ checked: themeMode === 'dark' }} accessibilityLabel="Dark theme" @@ -798,7 +817,13 @@ export function SettingsContent({ onDismiss }: SettingsContentProps) { setThemeMode('system')} + onPress={() => { + setThemeMode('system'); + trackEvent(AnalyticsEvents.SETTINGS_CHANGED, { + setting: 'theme', + value: 'system', + }); + }} accessibilityRole="radio" accessibilityState={{ checked: themeMode === 'system' }} accessibilityLabel="System theme" diff --git a/components/sheets/EditSavingsSheet.tsx b/components/sheets/EditSavingsSheet.tsx index 04e29254..4ea09740 100644 --- a/components/sheets/EditSavingsSheet.tsx +++ b/components/sheets/EditSavingsSheet.tsx @@ -18,6 +18,8 @@ import { showConfirm } from '@/lib/alert'; import { showToast } from '@/lib/toast'; import { logger, LogCategory } from '@/lib/logger'; import GlassBottomSheet, { GlassBottomSheetRef } from '@/components/GlassBottomSheet'; +import { trackEvent } from '@/lib/analytics'; +import { AnalyticsEvents } from '@/types/analytics'; import type { Profile } from '@/types/database'; import type { SpendingFrequency } from '@/lib/savings'; @@ -200,6 +202,12 @@ const EditSavingsSheet = forwardRef( if (updateError) throw updateError; + trackEvent(AnalyticsEvents.SAVINGS_UPDATED, { + amount: parseFloat(amount), + frequency, + is_first_setup: isSetupMode, + }); + showToast.success('Savings tracking updated'); await onSave(); sheetRef.current?.dismiss(); @@ -211,7 +219,7 @@ const EditSavingsSheet = forwardRef( } finally { setIsSaving(false); } - }, [amount, frequency, profile.id, validateAmount, onSave]); + }, [amount, frequency, profile.id, validateAmount, onSave, isSetupMode]); /** * Handles clearing all spending data after confirmation. diff --git a/components/tasks/TaskCard.tsx b/components/tasks/TaskCard.tsx index 38067fce..28026f9e 100644 --- a/components/tasks/TaskCard.tsx +++ b/components/tasks/TaskCard.tsx @@ -79,13 +79,28 @@ const TaskCard = memo(function TaskCard({ {variant === 'my-task' && !isCompleted && ( {new Date(task.created_at).toLocaleDateString()} )} - {variant === 'my-task' && isCompleted && } + {variant === 'my-task' && isCompleted && ( + + + + )} {variant === 'managed-task' && task.status === 'completed' && ( - + + + + )} + {variant === 'managed-task' && isOverdue && ( + + + )} - {variant === 'managed-task' && isOverdue && } {variant === 'managed-task' && !isCompleted && !isOverdue && ( - + + + )} @@ -100,7 +115,11 @@ const TaskCard = memo(function TaskCard({ {/* Due Date */} {task.due_date && variant === 'my-task' && ( - + Due {parseDateAsLocal(task.due_date).toLocaleDateString()} @@ -108,7 +127,11 @@ const TaskCard = memo(function TaskCard({ )} {task.due_date && variant === 'managed-task' && ( - + Due {parseDateAsLocal(task.due_date).toLocaleDateString()} diff --git a/docs/plans/2025-12-26-amplitude-analytics-implementation.md b/docs/plans/2025-12-26-amplitude-analytics-implementation.md new file mode 100644 index 00000000..49f2767d --- /dev/null +++ b/docs/plans/2025-12-26-amplitude-analytics-implementation.md @@ -0,0 +1,1655 @@ +# Amplitude Analytics Migration - Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace Firebase Analytics with Amplitude SDK, adopting Title Case event naming and adding 15 new events. + +**Architecture:** Clean swap - remove all Firebase dependencies, replace with `@amplitude/analytics-react-native`. Same public API, different internal implementation. + +**Tech Stack:** `@amplitude/analytics-react-native`, Expo Router, TypeScript + +**Working Directory:** `/Users/billchirico/Developer/Volvox/sobers/.worktrees/feat-amplitude-analytics` + +--- + +## Task 1: Update Dependencies + +**Files:** + +- Modify: `package.json` + +**Step 1: Remove Firebase dependencies** + +```bash +pnpm remove @react-native-firebase/analytics @react-native-firebase/app firebase +``` + +**Step 2: Verify removal** + +Run: `pnpm list | grep -i firebase` +Expected: No output (no Firebase packages) + +**Step 3: Add Amplitude SDK** + +```bash +pnpm add @amplitude/analytics-react-native +``` + +**Step 4: Verify installation** + +Run: `pnpm list @amplitude/analytics-react-native` +Expected: Shows installed version + +**Step 5: Commit** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "$(cat <<'EOF' +chore(deps): replace Firebase Analytics with Amplitude SDK + +- Remove @react-native-firebase/analytics +- Remove @react-native-firebase/app +- Remove firebase +- Add @amplitude/analytics-react-native +EOF +)" +``` + +--- + +## Task 2: Update Analytics Types + +**Files:** + +- Modify: `types/analytics.ts` +- Test: `__tests__/types/analytics.test.ts` + +**Step 1: Update the types file** + +Replace entire contents of `types/analytics.ts`: + +```typescript +// types/analytics.ts +/** + * Amplitude Analytics type definitions for Sobers. + * + * These types define the contract for analytics events and user properties + * tracked across all platforms (iOS, Android, web). + * + * @module types/analytics + */ + +/** + * Allowed values for event parameters. + * Amplitude supports strings, numbers, booleans, and arrays. + */ +export type EventParamValue = string | number | boolean | string[] | undefined; + +/** + * Generic event parameters object. + * All custom event parameters must use this interface. + */ +export interface EventParams { + [key: string]: EventParamValue; +} + +/** + * Bucketed ranges for days sober. + * We use buckets instead of exact values to protect user privacy. + */ +export type DaysSoberBucket = '0-7' | '8-30' | '31-90' | '91-180' | '181-365' | '365+'; + +/** + * Bucketed ranges for steps completed count. + */ +export type StepsCompletedBucket = '0' | '1-3' | '4-6' | '7-9' | '10-12'; + +/** + * User properties tracked in Amplitude Analytics. + * These are set once and persist across sessions until changed. + */ +export interface UserProperties { + /** Bucketed sobriety duration for cohort analysis */ + days_sober_bucket?: DaysSoberBucket; + /** Whether user has an active sponsor relationship */ + has_sponsor?: boolean; + /** Whether user is sponsoring others */ + has_sponsees?: boolean; + /** User's theme preference */ + theme_preference?: 'light' | 'dark' | 'system'; + /** Authentication method used */ + sign_in_method?: 'email' | 'google' | 'apple'; + /** Whether user completed onboarding */ + onboarding_completed?: boolean; + /** Current task streak count */ + task_streak_current?: number; + /** Bucketed 12-step progress */ + steps_completed_count?: StepsCompletedBucket; + /** Whether user has set a savings goal */ + savings_goal_set?: boolean; +} + +/** + * Amplitude configuration. + * Uses a single API key for all platforms. + */ +export interface AnalyticsConfig { + apiKey: string; +} + +/** + * Analytics event names used throughout the app. + * Using Title Case per Amplitude conventions for better dashboard rendering. + */ +export const AnalyticsEvents = { + // Authentication + AUTH_SIGN_UP: 'Auth Sign Up', + AUTH_LOGIN: 'Auth Login', + AUTH_LOGOUT: 'Auth Logout', + + // Onboarding + ONBOARDING_STARTED: 'Onboarding Started', + ONBOARDING_STEP_COMPLETED: 'Onboarding Step Completed', + ONBOARDING_SOBRIETY_DATE_SET: 'Onboarding Sobriety Date Set', + ONBOARDING_COMPLETED: 'Onboarding Completed', + ONBOARDING_SCREEN_VIEWED: 'Onboarding Screen Viewed', + ONBOARDING_FIELD_COMPLETED: 'Onboarding Field Completed', + ONBOARDING_ABANDONED: 'Onboarding Abandoned', + + // Core Features + SCREEN_VIEWED: 'Screen Viewed', + TASK_VIEWED: 'Task Viewed', + TASK_STARTED: 'Task Started', + TASK_COMPLETED: 'Task Completed', + TASK_CREATED: 'Task Created', + TASK_SKIPPED: 'Task Skipped', + TASK_STREAK_UPDATED: 'Task Streak Updated', + + // Steps (12-step) + STEP_VIEWED: 'Step Viewed', + STEP_STARTED: 'Step Started', + STEP_PROGRESS_SAVED: 'Step Progress Saved', + STEP_COMPLETED: 'Step Completed', + + // Milestones + MILESTONE_REACHED: 'Milestone Reached', + MILESTONE_SHARED: 'Milestone Shared', + MILESTONE_CELEBRATED: 'Milestone Celebrated', + + // Social + SPONSOR_CONNECTED: 'Sponsor Connected', + SPONSOR_INVITE_SENT: 'Sponsor Invite Sent', + SPONSOR_INVITE_ACCEPTED: 'Sponsor Invite Accepted', + SPONSEE_ADDED: 'Sponsee Added', + MESSAGE_SENT: 'Message Sent', + MESSAGE_READ: 'Message Read', + + // Engagement + APP_OPENED: 'App Opened', + APP_BACKGROUNDED: 'App Backgrounded', + APP_SESSION_STARTED: 'App Session Started', + DAILY_CHECK_IN: 'Daily Check In', + + // Settings + SETTINGS_CHANGED: 'Settings Changed', + + // Savings + SAVINGS_GOAL_SET: 'Savings Goal Set', + SAVINGS_UPDATED: 'Savings Updated', +} as const; + +/** + * Type for valid analytics event names. + */ +export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents]; +``` + +**Step 2: Update the types test file** + +Replace entire contents of `__tests__/types/analytics.test.ts`: + +```typescript +// __tests__/types/analytics.test.ts +import { + AnalyticsEvents, + type AnalyticsEventName, + type EventParams, + type UserProperties, + type DaysSoberBucket, + type StepsCompletedBucket, + type AnalyticsConfig, +} from '@/types/analytics'; + +describe('types/analytics', () => { + describe('AnalyticsEvents', () => { + it('should have all required event constants', () => { + // Auth events + expect(AnalyticsEvents.AUTH_SIGN_UP).toBe('Auth Sign Up'); + expect(AnalyticsEvents.AUTH_LOGIN).toBe('Auth Login'); + expect(AnalyticsEvents.AUTH_LOGOUT).toBe('Auth Logout'); + + // Onboarding events + expect(AnalyticsEvents.ONBOARDING_STARTED).toBe('Onboarding Started'); + expect(AnalyticsEvents.ONBOARDING_COMPLETED).toBe('Onboarding Completed'); + expect(AnalyticsEvents.ONBOARDING_SCREEN_VIEWED).toBe('Onboarding Screen Viewed'); + expect(AnalyticsEvents.ONBOARDING_FIELD_COMPLETED).toBe('Onboarding Field Completed'); + expect(AnalyticsEvents.ONBOARDING_ABANDONED).toBe('Onboarding Abandoned'); + + // Task events + expect(AnalyticsEvents.TASK_COMPLETED).toBe('Task Completed'); + expect(AnalyticsEvents.TASK_CREATED).toBe('Task Created'); + expect(AnalyticsEvents.TASK_SKIPPED).toBe('Task Skipped'); + expect(AnalyticsEvents.TASK_STREAK_UPDATED).toBe('Task Streak Updated'); + + // Step events + expect(AnalyticsEvents.STEP_VIEWED).toBe('Step Viewed'); + expect(AnalyticsEvents.STEP_STARTED).toBe('Step Started'); + expect(AnalyticsEvents.STEP_PROGRESS_SAVED).toBe('Step Progress Saved'); + expect(AnalyticsEvents.STEP_COMPLETED).toBe('Step Completed'); + + // Milestone events + expect(AnalyticsEvents.MILESTONE_REACHED).toBe('Milestone Reached'); + expect(AnalyticsEvents.MILESTONE_CELEBRATED).toBe('Milestone Celebrated'); + + // Social events + expect(AnalyticsEvents.SPONSEE_ADDED).toBe('Sponsee Added'); + expect(AnalyticsEvents.MESSAGE_READ).toBe('Message Read'); + + // Engagement events + expect(AnalyticsEvents.APP_BACKGROUNDED).toBe('App Backgrounded'); + expect(AnalyticsEvents.APP_SESSION_STARTED).toBe('App Session Started'); + + // Settings events + expect(AnalyticsEvents.SETTINGS_CHANGED).toBe('Settings Changed'); + + // Savings events + expect(AnalyticsEvents.SAVINGS_GOAL_SET).toBe('Savings Goal Set'); + expect(AnalyticsEvents.SAVINGS_UPDATED).toBe('Savings Updated'); + }); + + it('should use Title Case naming convention', () => { + const eventValues = Object.values(AnalyticsEvents); + eventValues.forEach((value) => { + // Title Case: first letter of each word is uppercase + const words = value.split(' '); + words.forEach((word) => { + expect(word[0]).toBe(word[0].toUpperCase()); + }); + }); + }); + + it('should have exactly 35 events', () => { + const eventCount = Object.keys(AnalyticsEvents).length; + expect(eventCount).toBe(35); + }); + }); + + describe('Type definitions', () => { + it('should allow valid EventParams', () => { + const params: EventParams = { + task_id: '123', + count: 5, + is_first: true, + tags: ['tag1', 'tag2'], + }; + expect(params).toBeDefined(); + }); + + it('should allow valid UserProperties', () => { + const props: UserProperties = { + days_sober_bucket: '31-90', + has_sponsor: true, + has_sponsees: false, + theme_preference: 'dark', + sign_in_method: 'google', + onboarding_completed: true, + task_streak_current: 7, + steps_completed_count: '4-6', + savings_goal_set: true, + }; + expect(props).toBeDefined(); + }); + + it('should allow valid DaysSoberBucket values', () => { + const buckets: DaysSoberBucket[] = ['0-7', '8-30', '31-90', '91-180', '181-365', '365+']; + expect(buckets).toHaveLength(6); + }); + + it('should allow valid StepsCompletedBucket values', () => { + const buckets: StepsCompletedBucket[] = ['0', '1-3', '4-6', '7-9', '10-12']; + expect(buckets).toHaveLength(5); + }); + + it('should allow valid AnalyticsConfig', () => { + const config: AnalyticsConfig = { + apiKey: 'test-api-key', + }; + expect(config.apiKey).toBe('test-api-key'); + }); + + it('should derive AnalyticsEventName from AnalyticsEvents values', () => { + const eventName: AnalyticsEventName = 'Auth Sign Up'; + expect(eventName).toBe(AnalyticsEvents.AUTH_SIGN_UP); + }); + }); +}); +``` + +**Step 3: Run tests to verify** + +Run: `pnpm test -- __tests__/types/analytics.test.ts` +Expected: All tests pass + +**Step 4: Run typecheck** + +Run: `pnpm typecheck` +Expected: No errors (may have errors in other files - that's expected, we'll fix them) + +**Step 5: Commit** + +```bash +git add types/analytics.ts __tests__/types/analytics.test.ts +git commit -m "$(cat <<'EOF' +feat(analytics): update types for Amplitude with Title Case events + +- Change event values to Title Case (Amplitude convention) +- Add 15 new events for comprehensive tracking +- Add new user properties (onboarding_completed, task_streak_current, etc.) +- Simplify AnalyticsConfig to single apiKey +- Add StepsCompletedBucket type +EOF +)" +``` + +--- + +## Task 3: Simplify Analytics Utils + +**Files:** + +- Modify: `lib/analytics-utils.ts` +- Modify: `__tests__/lib/analytics-utils.test.ts` + +**Step 1: Update analytics-utils.ts** + +Replace entire contents: + +```typescript +// lib/analytics-utils.ts +/** + * Utility functions for Amplitude Analytics. + * + * These helpers handle PII sanitization, bucket calculations, + * and initialization checks. + * + * @module lib/analytics-utils + */ + +import type { EventParams, DaysSoberBucket, StepsCompletedBucket } from '@/types/analytics'; + +/** + * PII fields that must be stripped from analytics events. + * These fields could identify users and must never be sent to analytics. + */ +const PII_FIELDS = [ + 'email', + 'name', + 'display_name', + 'phone', + 'password', + 'token', + 'access_token', + 'refresh_token', + 'sobriety_date', + 'relapse_date', +] as const; + +/** + * Maximum recursion depth to prevent infinite loops. + */ +const MAX_DEPTH = 10; + +/** + * Recursively removes PII fields from event parameters at any depth. + */ +function sanitizeValue( + value: unknown, + visited: WeakSet = new WeakSet(), + depth: number = 0 +): unknown { + if (depth > MAX_DEPTH) return value; + if (value === null || value === undefined) return value; + + if (Array.isArray(value)) { + return value.map((item) => sanitizeValue(item, visited, depth + 1)); + } + + if (typeof value === 'object') { + if (visited.has(value)) return undefined; + visited.add(value); + + const sanitized: Record = {}; + for (const [key, val] of Object.entries(value)) { + if (PII_FIELDS.includes(key as (typeof PII_FIELDS)[number])) continue; + sanitized[key] = sanitizeValue(val, visited, depth + 1); + } + + visited.delete(value); + return sanitized; + } + + return value; +} + +/** + * Removes PII fields from event parameters before sending to analytics. + */ +export function sanitizeParams(params: EventParams | undefined): EventParams { + if (!params) return {}; + const sanitized = sanitizeValue(params) as EventParams; + return sanitized || {}; +} + +/** + * Calculates the appropriate bucket for days sober. + */ +export function calculateDaysSoberBucket(days: number): DaysSoberBucket { + if (days <= 7) return '0-7'; + if (days <= 30) return '8-30'; + if (days <= 90) return '31-90'; + if (days <= 180) return '91-180'; + if (days <= 365) return '181-365'; + return '365+'; +} + +/** + * Calculates the appropriate bucket for steps completed. + */ +export function calculateStepsCompletedBucket(count: number): StepsCompletedBucket { + if (count === 0) return '0'; + if (count <= 3) return '1-3'; + if (count <= 6) return '4-6'; + if (count <= 9) return '7-9'; + return '10-12'; +} + +/** + * Checks if Amplitude Analytics should be initialized. + * Returns true if the API key is configured. + */ +export function shouldInitializeAnalytics(): boolean { + const apiKey = process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY?.trim(); + return Boolean(apiKey && apiKey.length > 0); +} + +/** + * Checks if the app is running in debug mode. + */ +export function isDebugMode(): boolean { + return __DEV__ || process.env.EXPO_PUBLIC_ANALYTICS_DEBUG === 'true'; +} + +/** + * Gets the current environment for analytics tagging. + */ +export function getAnalyticsEnvironment(): string { + if (__DEV__) return 'development'; + return process.env.EXPO_PUBLIC_APP_ENV || 'production'; +} +``` + +**Step 2: Update analytics-utils test** + +Replace entire contents of `__tests__/lib/analytics-utils.test.ts`: + +```typescript +// __tests__/lib/analytics-utils.test.ts +import { + sanitizeParams, + calculateDaysSoberBucket, + calculateStepsCompletedBucket, + shouldInitializeAnalytics, + isDebugMode, + getAnalyticsEnvironment, +} from '@/lib/analytics-utils'; + +describe('lib/analytics-utils', () => { + describe('sanitizeParams', () => { + it('should return empty object for undefined', () => { + expect(sanitizeParams(undefined)).toEqual({}); + }); + + it('should remove PII fields', () => { + const params = { + task_id: '123', + email: 'test@example.com', + name: 'John', + count: 5, + }; + expect(sanitizeParams(params)).toEqual({ task_id: '123', count: 5 }); + }); + + it('should handle nested objects', () => { + const params = { + task_id: '123', + metadata: { email: 'test@example.com', value: 42 }, + }; + expect(sanitizeParams(params)).toEqual({ + task_id: '123', + metadata: { value: 42 }, + }); + }); + + it('should handle arrays', () => { + const params = { + items: [{ email: 'a@b.com', id: 1 }, { id: 2 }], + }; + expect(sanitizeParams(params)).toEqual({ + items: [{ id: 1 }, { id: 2 }], + }); + }); + }); + + describe('calculateDaysSoberBucket', () => { + it.each([ + [0, '0-7'], + [7, '0-7'], + [8, '8-30'], + [30, '8-30'], + [31, '31-90'], + [90, '31-90'], + [91, '91-180'], + [180, '91-180'], + [181, '181-365'], + [365, '181-365'], + [366, '365+'], + [1000, '365+'], + ])('should return %s for %d days', (days, expected) => { + expect(calculateDaysSoberBucket(days)).toBe(expected); + }); + }); + + describe('calculateStepsCompletedBucket', () => { + it.each([ + [0, '0'], + [1, '1-3'], + [3, '1-3'], + [4, '4-6'], + [6, '4-6'], + [7, '7-9'], + [9, '7-9'], + [10, '10-12'], + [12, '10-12'], + ])('should return %s for %d steps', (count, expected) => { + expect(calculateStepsCompletedBucket(count)).toBe(expected); + }); + }); + + describe('shouldInitializeAnalytics', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should return true when API key is set', () => { + process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY = 'test-key'; + expect(shouldInitializeAnalytics()).toBe(true); + }); + + it('should return false when API key is empty', () => { + process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY = ''; + expect(shouldInitializeAnalytics()).toBe(false); + }); + + it('should return false when API key is whitespace', () => { + process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY = ' '; + expect(shouldInitializeAnalytics()).toBe(false); + }); + + it('should return false when API key is not set', () => { + delete process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY; + expect(shouldInitializeAnalytics()).toBe(false); + }); + }); + + describe('isDebugMode', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should return true in __DEV__ mode', () => { + // __DEV__ is true in test environment + expect(isDebugMode()).toBe(true); + }); + + it('should return true when EXPO_PUBLIC_ANALYTICS_DEBUG is true', () => { + process.env.EXPO_PUBLIC_ANALYTICS_DEBUG = 'true'; + expect(isDebugMode()).toBe(true); + }); + }); + + describe('getAnalyticsEnvironment', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should return development in __DEV__ mode', () => { + expect(getAnalyticsEnvironment()).toBe('development'); + }); + }); +}); +``` + +**Step 3: Run tests** + +Run: `pnpm test -- __tests__/lib/analytics-utils.test.ts` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add lib/analytics-utils.ts __tests__/lib/analytics-utils.test.ts +git commit -m "$(cat <<'EOF' +refactor(analytics): simplify utils for Amplitude + +- Remove Platform import (no longer needed) +- Update shouldInitializeAnalytics to check AMPLITUDE_API_KEY +- Add calculateStepsCompletedBucket helper +- Update tests for new behavior +EOF +)" +``` + +--- + +## Task 4: Implement Amplitude Platform (Native) + +**Files:** + +- Modify: `lib/analytics/platform.native.ts` +- Modify: `__tests__/lib/analytics.native.test.ts` + +**Step 1: Create Amplitude mock in jest.setup.js** + +Add to `jest.setup.js` (after existing mocks): + +```javascript +// Mock @amplitude/analytics-react-native +jest.mock('@amplitude/analytics-react-native', () => ({ + init: jest.fn().mockResolvedValue(undefined), + track: jest.fn(), + identify: jest.fn(), + setUserId: jest.fn(), + reset: jest.fn().mockResolvedValue(undefined), + Identify: jest.fn().mockImplementation(() => ({ + set: jest.fn().mockReturnThis(), + })), +})); +``` + +**Step 2: Update platform.native.ts** + +Replace entire contents: + +```typescript +// lib/analytics/platform.native.ts +/** + * Amplitude Analytics implementation for native platforms (iOS/Android). + * + * This file is automatically selected by Metro bundler on iOS and Android. + * + * @module lib/analytics/platform.native + */ + +import * as amplitude from '@amplitude/analytics-react-native'; + +import type { EventParams, UserProperties, AnalyticsConfig } from '@/types/analytics'; +import { isDebugMode } from '@/lib/analytics-utils'; +import { logger, LogCategory } from '@/lib/logger'; + +let isInitialized = false; + +/** + * Initializes Amplitude Analytics for native platforms. + */ +export async function initializePlatformAnalytics(config: AnalyticsConfig): Promise { + if (isInitialized) { + if (isDebugMode()) { + logger.debug('Amplitude already initialized', { category: LogCategory.ANALYTICS }); + } + return; + } + + try { + await amplitude.init(config.apiKey, undefined, { + logLevel: isDebugMode() ? amplitude.Types.LogLevel.Debug : amplitude.Types.LogLevel.None, + }); + + isInitialized = true; + + if (isDebugMode()) { + logger.info('Amplitude Analytics initialized for native', { + category: LogCategory.ANALYTICS, + }); + } + } catch (error) { + logger.error( + 'Failed to initialize Amplitude', + error instanceof Error ? error : new Error(String(error)), + { category: LogCategory.ANALYTICS } + ); + } +} + +/** + * Tracks an analytics event. + */ +export function trackEventPlatform(eventName: string, params?: EventParams): void { + if (isDebugMode()) { + logger.debug(`Event: ${eventName}`, { category: LogCategory.ANALYTICS, ...params }); + } + + if (!isInitialized) return; + + amplitude.track(eventName, params); +} + +/** + * Sets the user ID for analytics. + */ +export function setUserIdPlatform(userId: string | null): void { + if (isDebugMode()) { + logger.debug(`setUserId: ${userId ? '' : 'null'}`, { category: LogCategory.ANALYTICS }); + } + + if (!isInitialized) return; + + amplitude.setUserId(userId ?? undefined); +} + +/** + * Sets user properties for analytics. + */ +export function setUserPropertiesPlatform(properties: UserProperties): void { + if (isDebugMode()) { + logger.debug('setUserProperties', { category: LogCategory.ANALYTICS, ...properties }); + } + + if (!isInitialized) return; + + const identify = new amplitude.Identify(); + for (const [key, value] of Object.entries(properties)) { + if (value !== undefined) { + identify.set(key, value); + } + } + amplitude.identify(identify); +} + +/** + * Tracks a screen view event. + */ +export function trackScreenViewPlatform(screenName: string, screenClass?: string): void { + trackEventPlatform('Screen Viewed', { + screen_name: screenName, + screen_class: screenClass || screenName, + }); +} + +/** + * Resets analytics state. + */ +export async function resetAnalyticsPlatform(): Promise { + if (isDebugMode()) { + logger.info('Resetting analytics state', { category: LogCategory.ANALYTICS }); + } + + if (!isInitialized) return; + + await amplitude.reset(); +} + +/** + * Reset for testing. + * @internal + */ +export function __resetForTesting(): void { + if (process.env.NODE_ENV !== 'test') { + throw new Error('__resetForTesting should only be called in test environments'); + } + isInitialized = false; +} +``` + +**Step 3: Update native test file** + +Replace entire contents of `__tests__/lib/analytics.native.test.ts`: + +```typescript +// __tests__/lib/analytics.native.test.ts +/** + * @jest-environment node + */ + +import * as amplitude from '@amplitude/analytics-react-native'; + +import { + initializePlatformAnalytics, + trackEventPlatform, + setUserIdPlatform, + setUserPropertiesPlatform, + trackScreenViewPlatform, + resetAnalyticsPlatform, + __resetForTesting, +} from '@/lib/analytics/platform.native'; + +jest.mock('@/lib/logger', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + LogCategory: { ANALYTICS: 'analytics' }, +})); + +describe('lib/analytics/platform.native', () => { + beforeEach(() => { + jest.clearAllMocks(); + __resetForTesting(); + }); + + describe('initializePlatformAnalytics', () => { + it('should initialize Amplitude with API key', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + expect(amplitude.init).toHaveBeenCalledWith('test-key', undefined, expect.any(Object)); + }); + + it('should not reinitialize if already initialized', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + expect(amplitude.init).toHaveBeenCalledTimes(1); + }); + }); + + describe('trackEventPlatform', () => { + it('should track event after initialization', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + trackEventPlatform('Test Event', { param: 'value' }); + + expect(amplitude.track).toHaveBeenCalledWith('Test Event', { param: 'value' }); + }); + + it('should not track event before initialization', () => { + trackEventPlatform('Test Event', { param: 'value' }); + + expect(amplitude.track).not.toHaveBeenCalled(); + }); + }); + + describe('setUserIdPlatform', () => { + it('should set user ID after initialization', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + setUserIdPlatform('user-123'); + + expect(amplitude.setUserId).toHaveBeenCalledWith('user-123'); + }); + + it('should clear user ID when null', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + setUserIdPlatform(null); + + expect(amplitude.setUserId).toHaveBeenCalledWith(undefined); + }); + }); + + describe('setUserPropertiesPlatform', () => { + it('should set user properties after initialization', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + setUserPropertiesPlatform({ has_sponsor: true, theme_preference: 'dark' }); + + expect(amplitude.identify).toHaveBeenCalled(); + }); + }); + + describe('trackScreenViewPlatform', () => { + it('should track screen view event', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + trackScreenViewPlatform('Home', 'TabScreen'); + + expect(amplitude.track).toHaveBeenCalledWith('Screen Viewed', { + screen_name: 'Home', + screen_class: 'TabScreen', + }); + }); + }); + + describe('resetAnalyticsPlatform', () => { + it('should reset analytics state', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + await resetAnalyticsPlatform(); + + expect(amplitude.reset).toHaveBeenCalled(); + }); + }); +}); +``` + +**Step 4: Run tests** + +Run: `pnpm test -- __tests__/lib/analytics.native.test.ts` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add lib/analytics/platform.native.ts __tests__/lib/analytics.native.test.ts jest.setup.js +git commit -m "$(cat <<'EOF' +feat(analytics): implement Amplitude SDK for native platforms + +- Replace Firebase with Amplitude SDK +- Add amplitude mock to jest.setup.js +- Update native platform implementation +- Use Identify class for user properties +EOF +)" +``` + +--- + +## Task 5: Implement Amplitude Platform (Web) + +**Files:** + +- Modify: `lib/analytics/platform.web.ts` +- Modify: `__tests__/lib/analytics.web.test.ts` + +**Step 1: Update platform.web.ts** + +Replace entire contents: + +```typescript +// lib/analytics/platform.web.ts +/** + * Amplitude Analytics implementation for web platform. + * + * This file is automatically selected by Metro bundler on web. + * + * @module lib/analytics/platform.web + */ + +import * as amplitude from '@amplitude/analytics-browser'; + +import type { EventParams, UserProperties, AnalyticsConfig } from '@/types/analytics'; +import { isDebugMode } from '@/lib/analytics-utils'; +import { logger, LogCategory } from '@/lib/logger'; + +let isInitialized = false; + +/** + * Initializes Amplitude Analytics for web platform. + */ +export async function initializePlatformAnalytics(config: AnalyticsConfig): Promise { + if (isInitialized) { + if (isDebugMode()) { + logger.debug('Amplitude already initialized', { category: LogCategory.ANALYTICS }); + } + return; + } + + try { + amplitude.init(config.apiKey, undefined, { + logLevel: isDebugMode() ? amplitude.Types.LogLevel.Debug : amplitude.Types.LogLevel.None, + }); + + isInitialized = true; + + if (isDebugMode()) { + logger.info('Amplitude Analytics initialized for web', { + category: LogCategory.ANALYTICS, + }); + } + } catch (error) { + logger.error( + 'Failed to initialize Amplitude', + error instanceof Error ? error : new Error(String(error)), + { category: LogCategory.ANALYTICS } + ); + } +} + +/** + * Tracks an analytics event. + */ +export function trackEventPlatform(eventName: string, params?: EventParams): void { + if (isDebugMode()) { + logger.debug(`Event: ${eventName}`, { category: LogCategory.ANALYTICS, ...params }); + } + + if (!isInitialized) return; + + amplitude.track(eventName, params); +} + +/** + * Sets the user ID for analytics. + */ +export function setUserIdPlatform(userId: string | null): void { + if (isDebugMode()) { + logger.debug(`setUserId: ${userId ? '' : 'null'}`, { category: LogCategory.ANALYTICS }); + } + + if (!isInitialized) return; + + amplitude.setUserId(userId ?? undefined); +} + +/** + * Sets user properties for analytics. + */ +export function setUserPropertiesPlatform(properties: UserProperties): void { + if (isDebugMode()) { + logger.debug('setUserProperties', { category: LogCategory.ANALYTICS, ...properties }); + } + + if (!isInitialized) return; + + const identify = new amplitude.Identify(); + for (const [key, value] of Object.entries(properties)) { + if (value !== undefined) { + identify.set(key, value); + } + } + amplitude.identify(identify); +} + +/** + * Tracks a screen view event. + */ +export function trackScreenViewPlatform(screenName: string, screenClass?: string): void { + trackEventPlatform('Screen Viewed', { + screen_name: screenName, + screen_class: screenClass || screenName, + }); +} + +/** + * Resets analytics state. + */ +export async function resetAnalyticsPlatform(): Promise { + if (isDebugMode()) { + logger.info('Resetting analytics state', { category: LogCategory.ANALYTICS }); + } + + if (!isInitialized) return; + + amplitude.reset(); +} + +/** + * Reset for testing. + * @internal + */ +export function __resetForTesting(): void { + if (process.env.NODE_ENV !== 'test') { + throw new Error('__resetForTesting should only be called in test environments'); + } + isInitialized = false; +} +``` + +**Step 2: Add browser mock to jest.setup.js** + +Add after the react-native mock: + +```javascript +// Mock @amplitude/analytics-browser +jest.mock('@amplitude/analytics-browser', () => ({ + init: jest.fn(), + track: jest.fn(), + identify: jest.fn(), + setUserId: jest.fn(), + reset: jest.fn(), + Identify: jest.fn().mockImplementation(() => ({ + set: jest.fn().mockReturnThis(), + })), + Types: { + LogLevel: { Debug: 0, None: 4 }, + }, +})); +``` + +**Step 3: Update web test file** + +Replace entire contents of `__tests__/lib/analytics.web.test.ts`: + +```typescript +// __tests__/lib/analytics.web.test.ts +import * as amplitude from '@amplitude/analytics-browser'; + +import { + initializePlatformAnalytics, + trackEventPlatform, + setUserIdPlatform, + setUserPropertiesPlatform, + trackScreenViewPlatform, + resetAnalyticsPlatform, + __resetForTesting, +} from '@/lib/analytics/platform.web'; + +jest.mock('@/lib/logger', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + LogCategory: { ANALYTICS: 'analytics' }, +})); + +describe('lib/analytics/platform.web', () => { + beforeEach(() => { + jest.clearAllMocks(); + __resetForTesting(); + }); + + describe('initializePlatformAnalytics', () => { + it('should initialize Amplitude with API key', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + expect(amplitude.init).toHaveBeenCalledWith('test-key', undefined, expect.any(Object)); + }); + + it('should not reinitialize if already initialized', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + expect(amplitude.init).toHaveBeenCalledTimes(1); + }); + }); + + describe('trackEventPlatform', () => { + it('should track event after initialization', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + trackEventPlatform('Test Event', { param: 'value' }); + + expect(amplitude.track).toHaveBeenCalledWith('Test Event', { param: 'value' }); + }); + + it('should not track event before initialization', () => { + trackEventPlatform('Test Event', { param: 'value' }); + + expect(amplitude.track).not.toHaveBeenCalled(); + }); + }); + + describe('setUserIdPlatform', () => { + it('should set user ID after initialization', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + setUserIdPlatform('user-123'); + + expect(amplitude.setUserId).toHaveBeenCalledWith('user-123'); + }); + + it('should clear user ID when null', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + setUserIdPlatform(null); + + expect(amplitude.setUserId).toHaveBeenCalledWith(undefined); + }); + }); + + describe('setUserPropertiesPlatform', () => { + it('should set user properties after initialization', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + setUserPropertiesPlatform({ has_sponsor: true, theme_preference: 'dark' }); + + expect(amplitude.identify).toHaveBeenCalled(); + }); + }); + + describe('trackScreenViewPlatform', () => { + it('should track screen view event', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + trackScreenViewPlatform('Home', 'TabScreen'); + + expect(amplitude.track).toHaveBeenCalledWith('Screen Viewed', { + screen_name: 'Home', + screen_class: 'TabScreen', + }); + }); + }); + + describe('resetAnalyticsPlatform', () => { + it('should reset analytics state', async () => { + await initializePlatformAnalytics({ apiKey: 'test-key' }); + + await resetAnalyticsPlatform(); + + expect(amplitude.reset).toHaveBeenCalled(); + }); + }); +}); +``` + +**Step 4: Run tests** + +Run: `pnpm test -- __tests__/lib/analytics.web.test.ts` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add lib/analytics/platform.web.ts __tests__/lib/analytics.web.test.ts jest.setup.js +git commit -m "$(cat <<'EOF' +feat(analytics): implement Amplitude SDK for web platform + +- Replace Firebase with Amplitude browser SDK +- Add amplitude browser mock to jest.setup.js +- Update web platform implementation +EOF +)" +``` + +--- + +## Task 6: Update Main Analytics Module + +**Files:** + +- Modify: `lib/analytics/index.ts` +- Modify: `__tests__/lib/analytics.test.ts` + +**Step 1: Update index.ts** + +Replace entire contents: + +```typescript +// lib/analytics/index.ts +/** + * Unified Amplitude Analytics module for Sobers. + * + * This is the ONLY module that app code should import for analytics. + * Metro automatically selects the correct platform implementation. + * + * @module lib/analytics + */ + +import type { EventParams, UserProperties, AnalyticsConfig } from '@/types/analytics'; +import { sanitizeParams, shouldInitializeAnalytics, isDebugMode } from '@/lib/analytics-utils'; +import { logger, LogCategory } from '@/lib/logger'; + +import { + initializePlatformAnalytics, + trackEventPlatform, + setUserIdPlatform, + setUserPropertiesPlatform, + trackScreenViewPlatform, + resetAnalyticsPlatform, +} from './platform'; + +// Re-export types and constants +export { AnalyticsEvents, type AnalyticsEventName } from '@/types/analytics'; +export { calculateDaysSoberBucket, calculateStepsCompletedBucket } from '@/lib/analytics-utils'; + +let initializationPromise: Promise | null = null; +let initializationState: 'pending' | 'completed' | 'skipped' | 'failed' | null = null; + +/** + * Initialize Amplitude Analytics for the app. + */ +export async function initializeAnalytics(): Promise { + if (initializationState === 'completed' || initializationState === 'skipped') { + return; + } + + if (initializationPromise !== null && initializationState === 'pending') { + return initializationPromise; + } + + if (!shouldInitializeAnalytics()) { + if (isDebugMode()) { + logger.warn('Amplitude not configured - analytics disabled', { + category: LogCategory.ANALYTICS, + }); + } + initializationState = 'skipped'; + return; + } + + const config: AnalyticsConfig = { + apiKey: process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY || '', + }; + + initializationState = 'pending'; + initializationPromise = initializePlatformAnalytics(config) + .then(() => { + initializationState = 'completed'; + }) + .catch((error) => { + initializationState = 'failed'; + initializationPromise = null; + logger.error( + 'Failed to initialize analytics', + error instanceof Error ? error : new Error(String(error)), + { + category: LogCategory.ANALYTICS, + } + ); + }); + + return initializationPromise; +} + +/** + * Tracks an analytics event. + */ +export function trackEvent(eventName: string, params?: EventParams): void { + const sanitized = sanitizeParams(params); + trackEventPlatform(eventName, sanitized); +} + +/** + * Sets the user ID for analytics. + */ +export function setUserId(userId: string | null): void { + setUserIdPlatform(userId); +} + +/** + * Sets user properties for analytics. + */ +export function setUserProperties(properties: UserProperties): void { + setUserPropertiesPlatform(properties); +} + +/** + * Tracks a screen view. + */ +export function trackScreenView(screenName: string, screenClass?: string): void { + trackScreenViewPlatform(screenName, screenClass); +} + +/** + * Resets analytics state. + */ +export async function resetAnalytics(): Promise { + await resetAnalyticsPlatform(); +} + +/** + * Reset for testing. + * @internal + */ +export function __resetForTesting(): void { + if (process.env.NODE_ENV !== 'test') { + throw new Error('__resetForTesting should only be called in test environments'); + } + initializationState = null; + initializationPromise = null; +} +``` + +**Step 2: Update the main test file** + +Update `__tests__/lib/analytics.test.ts` to test the new implementation (keep existing test structure, update mocks and expectations for Amplitude). + +**Step 3: Run all analytics tests** + +Run: `pnpm test -- __tests__/lib/analytics` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add lib/analytics/index.ts __tests__/lib/analytics.test.ts +git commit -m "$(cat <<'EOF' +refactor(analytics): update main module for Amplitude + +- Simplify config to single apiKey +- Update initialization to check AMPLITUDE_API_KEY +- Export calculateStepsCompletedBucket +EOF +)" +``` + +--- + +## Task 7: Update App Configuration + +**Files:** + +- Modify: `app.config.ts` +- Modify: `.env.example` + +**Step 1: Update app.config.ts** + +Remove Firebase-related configuration: + +- Remove `googleServicesFile` from ios config +- Remove `googleServicesFile` from android config +- Remove `'./plugins/withModularHeaders'` from plugins +- Remove `'@react-native-firebase/app'` from plugins +- Update documentation comments + +**Step 2: Update .env.example** + +Replace Firebase section with Amplitude: + +```env +# ============================================================================== +# AMPLITUDE ANALYTICS +# ============================================================================== +# Amplitude Dashboard: https://app.amplitude.com +# Get API key from: Settings > Projects > [Your Project] > General +EXPO_PUBLIC_AMPLITUDE_API_KEY=your-amplitude-api-key-here + +# Optional: Enable debug mode for analytics (shows events in console) +EXPO_PUBLIC_ANALYTICS_DEBUG=false +``` + +**Step 3: Verify typecheck passes** + +Run: `pnpm typecheck` +Expected: No errors + +**Step 4: Commit** + +```bash +git add app.config.ts .env.example +git commit -m "$(cat <<'EOF' +chore(config): remove Firebase config, add Amplitude + +- Remove googleServicesFile from iOS/Android config +- Remove Firebase plugins from app.config.ts +- Replace Firebase env vars with AMPLITUDE_API_KEY +EOF +)" +``` + +--- + +## Task 8: Delete Firebase Files + +**Files:** + +- Delete: `plugins/withFirebaseConfig.js` +- Delete: `plugins/withModularHeaders.js` +- Delete: `firebase.json` +- Delete: `__tests__/plugins/withFirebaseConfig.test.ts` +- Delete: `__tests__/plugins/withModularHeaders.test.ts` + +**Step 1: Delete files** + +```bash +rm -f plugins/withFirebaseConfig.js +rm -f plugins/withModularHeaders.js +rm -f firebase.json +rm -f __tests__/plugins/withFirebaseConfig.test.ts +rm -f __tests__/plugins/withModularHeaders.test.ts +``` + +**Step 2: Verify deletion** + +Run: `ls plugins/ && ls __tests__/plugins/ 2>/dev/null || echo "plugins test dir empty/removed"` + +**Step 3: Commit** + +```bash +git add -A +git commit -m "$(cat <<'EOF' +chore(cleanup): remove Firebase plugin files + +- Delete withFirebaseConfig.js plugin +- Delete withModularHeaders.js plugin +- Delete firebase.json +- Delete associated test files +EOF +)" +``` + +--- + +## Task 9: Run Full Quality Suite + +**Step 1: Format code** + +Run: `pnpm format` + +**Step 2: Lint code** + +Run: `pnpm lint` +Expected: No errors + +**Step 3: Type check** + +Run: `pnpm typecheck` +Expected: No errors + +**Step 4: Build web** + +Run: `pnpm build:web` +Expected: Build succeeds + +**Step 5: Run all tests** + +Run: `pnpm test` +Expected: All tests pass with 80%+ coverage + +**Step 6: Commit any formatting changes** + +```bash +git add -A +git commit -m "style: format code after Amplitude migration" || echo "Nothing to commit" +``` + +--- + +## Task 10: Update CHANGELOG + +**Files:** + +- Modify: `CHANGELOG.md` + +**Step 1: Add changelog entry** + +Add under `## [Unreleased]`: + +```markdown +### Added + +- Amplitude Analytics integration for product analytics +- 15 new analytics events for comprehensive user journey tracking +- New user properties: onboarding_completed, task_streak_current, steps_completed_count, savings_goal_set +- calculateStepsCompletedBucket utility function + +### Changed + +- Analytics events now use Title Case naming (Amplitude convention) +- Analytics configuration simplified to single API key + +### Removed + +- Firebase Analytics SDK and all related dependencies +- Firebase configuration files and Expo plugins +- Firebase-specific environment variables +``` + +**Step 2: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs(changelog): document Amplitude migration" +``` + +--- + +## Task 11: Final Verification + +**Step 1: Run full quality suite one more time** + +```bash +pnpm format && pnpm lint && pnpm typecheck && pnpm build:web && pnpm test +``` + +Expected: All pass + +**Step 2: Review git log** + +```bash +git log --oneline -15 +``` + +Expected: Clean commit history with conventional commits + +**Step 3: Report completion** + +The migration is complete when: + +- [ ] All Firebase dependencies removed +- [ ] Amplitude SDK installed and configured +- [ ] All 35 events defined with Title Case naming +- [ ] 9 user properties defined +- [ ] All tests passing (80%+ coverage) +- [ ] No TypeScript errors +- [ ] CHANGELOG updated + +--- + +## Future Tasks (Not in this plan) + +The following require instrumenting new events in existing components - create a separate plan: + +- Add `trackEvent()` calls for the 15 new events in relevant screens/components +- Update existing `trackEvent()` calls to use new event names from `AnalyticsEvents` diff --git a/firebase.json b/firebase.json deleted file mode 100644 index 4dc80a4e..00000000 --- a/firebase.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "react-native": { - "analytics_auto_collection_enabled": true, - "google_analytics_automatic_screen_reporting_enabled": false - } -} diff --git a/jest.setup.js b/jest.setup.js index d68f0ccd..89da875a 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -92,6 +92,11 @@ jest.mock('react-native', () => { getInitialURL: jest.fn().mockResolvedValue(null), addEventListener: jest.fn().mockReturnValue({ remove: jest.fn() }), }, + AppState: { + currentState: 'active', + addEventListener: jest.fn().mockReturnValue({ remove: jest.fn() }), + removeEventListener: jest.fn(), + }, Dimensions: { get: jest.fn(() => ({ width: 375, height: 812 })), addEventListener: jest.fn(), @@ -729,6 +734,36 @@ jest.mock('expo-application', () => ({ applicationName: 'Sobers', })); +// Mock @amplitude/analytics-react-native +jest.mock('@amplitude/analytics-react-native', () => ({ + init: jest.fn().mockReturnValue({ promise: Promise.resolve() }), + track: jest.fn(), + identify: jest.fn(), + setUserId: jest.fn(), + reset: jest.fn().mockResolvedValue(undefined), + Identify: jest.fn().mockImplementation(() => ({ + set: jest.fn().mockReturnThis(), + })), + Types: { + LogLevel: { Debug: 0, None: 4 }, + }, +})); + +// Mock @amplitude/analytics-browser +jest.mock('@amplitude/analytics-browser', () => ({ + init: jest.fn().mockReturnValue({ promise: Promise.resolve() }), + track: jest.fn(), + identify: jest.fn(), + setUserId: jest.fn(), + reset: jest.fn(), + Identify: jest.fn().mockImplementation(() => ({ + set: jest.fn().mockReturnThis(), + })), + Types: { + LogLevel: { Debug: 0, None: 4 }, + }, +})); + // Mock react-native-reanimated jest.mock('react-native-reanimated', () => { const React = require('react'); diff --git a/lib/analytics-utils.ts b/lib/analytics-utils.ts index 3ae465cc..adb5c33d 100644 --- a/lib/analytics-utils.ts +++ b/lib/analytics-utils.ts @@ -1,6 +1,6 @@ // lib/analytics-utils.ts /** - * Utility functions for Firebase Analytics. + * Utility functions for Amplitude Analytics. * * These helpers handle PII sanitization, bucket calculations, * and initialization checks. @@ -8,13 +8,11 @@ * @module lib/analytics-utils */ -import { Platform } from 'react-native'; - -import type { EventParams, DaysSoberBucket } from '@/types/analytics'; +import type { EventParams, DaysSoberBucket, StepsCompletedBucket } from '@/types/analytics'; /** * PII fields that must be stripped from analytics events. - * These fields could identify users and must never be sent to Firebase. + * These fields could identify users and must never be sent to analytics. */ const PII_FIELDS = [ 'email', @@ -31,111 +29,65 @@ const PII_FIELDS = [ /** * Maximum recursion depth to prevent infinite loops. - * This limits how deep we traverse nested objects/arrays. */ const MAX_DEPTH = 10; /** - * Recursively removes PII fields from event parameters at any depth. + * Remove personally identifiable information (PII) keys from a value recursively. + * + * Recursively traverses arrays and plain objects and returns a copy with any keys listed in `PII_FIELDS` removed at all depths. Tracks visited objects to break reference cycles (in which case `undefined` is returned for that branch) and stops recursion when `depth` exceeds `MAX_DEPTH`, returning the value unchanged. * - * @param value - The value to sanitize (can be object, array, or primitive) - * @param visited - WeakSet tracking visited objects to prevent circular references - * @param depth - Current recursion depth (starts at 0) - * @returns Sanitized value with PII fields removed + * @param value - The value to sanitize; may be a primitive, array, or object. + * @param visited - Internal WeakSet used to track objects already traversed to avoid cycles. + * @param depth - Current recursion depth; when greater than `MAX_DEPTH` the original `value` is returned. + * @returns The sanitized value with PII keys removed, `undefined` for a cyclic branch, or the original value if recursion depth is exceeded. */ function sanitizeValue( value: unknown, visited: WeakSet = new WeakSet(), depth: number = 0 ): unknown { - // Safety: prevent infinite recursion from excessive depth - if (depth > MAX_DEPTH) { - return value; - } + if (depth > MAX_DEPTH) return value; + if (value === null || value === undefined) return value; - // Handle null and undefined - if (value === null || value === undefined) { - return value; - } - - // Handle arrays - recursively sanitize each element if (Array.isArray(value)) { return value.map((item) => sanitizeValue(item, visited, depth + 1)); } - // Handle objects - recursively sanitize properties if (typeof value === 'object') { - // Detect circular references - if (visited.has(value)) { - // Return undefined for circular references to prevent infinite loops - return undefined; - } - - // Add to visited set for this recursion path + if (visited.has(value)) return undefined; visited.add(value); const sanitized: Record = {}; - for (const [key, val] of Object.entries(value)) { - // Skip PII fields at any depth - if (PII_FIELDS.includes(key as (typeof PII_FIELDS)[number])) { - continue; - } - - // Recursively sanitize nested values + if (PII_FIELDS.includes(key as (typeof PII_FIELDS)[number])) continue; sanitized[key] = sanitizeValue(val, visited, depth + 1); } - // Remove from visited set after processing (allows same object in different branches) visited.delete(value); - return sanitized; } - // Primitives (string, number, boolean) pass through unchanged return value; } /** - * Removes PII fields from event parameters before sending to Firebase. - * Recursively traverses nested objects and arrays to sanitize PII at any depth. - * - * @param params - The event parameters to sanitize - * @returns Sanitized parameters with PII fields removed at all depths - * - * @example - * ```ts - * const safe = sanitizeParams({ task_id: '123', email: 'user@test.com' }); - * // Returns: { task_id: '123' } + * Strip PII fields from event parameters. * - * const nested = sanitizeParams({ - * task_id: '123', - * metadata: { email: 'user@test.com', name: 'John' } - * }); - * // Returns: { task_id: '123', metadata: {} } - * ``` + * @param params - Event parameters to sanitize; may be undefined. + * @returns The parameters with PII keys removed; returns an empty object if `params` is falsy or sanitization fails. */ export function sanitizeParams(params: EventParams | undefined): EventParams { if (!params) return {}; - const sanitized = sanitizeValue(params) as EventParams; return sanitized || {}; } /** - * Calculates the appropriate bucket for days sober. - * - * We use buckets instead of exact values to protect user privacy - * while still enabling cohort analysis in Firebase. - * - * @param days - The number of days sober - * @returns The bucket range the days fall into + * Determine the bucket label for a given number of sober days. * - * @example - * ```ts - * calculateDaysSoberBucket(45); // Returns: '31-90' - * calculateDaysSoberBucket(400); // Returns: '365+' - * ``` + * @param days - The number of days sober used to select a bucket. + * @returns One of: `'0-7'`, `'8-30'`, `'31-90'`, `'91-180'`, `'181-365'`, or `'365+'`. Each numeric range is inclusive of its lower and upper bounds; `'365+'` represents values greater than 365. */ export function calculateDaysSoberBucket(days: number): DaysSoberBucket { if (days <= 7) return '0-7'; @@ -147,45 +99,42 @@ export function calculateDaysSoberBucket(days: number): DaysSoberBucket { } /** - * Checks if Firebase Analytics should be initialized. + * Map a steps-completed count to its predefined bucket label. * - * On native platforms (iOS/Android), Firebase is configured via platform-specific - * config files (GoogleService-Info.plist / google-services.json), so we always - * attempt initialization - the native SDK validates its own config. - * - * On web, we check for the measurement ID environment variable since web - * requires explicit configuration via environment variables. + * @param count - The number of steps completed in a multi-step flow + * @returns `'0'` if `count` <= 0, `'1-3'` if `count` is between 1 and 3, `'4-6'` if between 4 and 6, `'7-9'` if between 7 and 9, otherwise `'10-12'` + */ +export function calculateStepsCompletedBucket(count: number): StepsCompletedBucket { + if (count <= 0) return '0'; + if (count <= 3) return '1-3'; + if (count <= 6) return '4-6'; + if (count <= 9) return '7-9'; + return '10-12'; +} + +/** + * Determine whether Amplitude Analytics should be initialized based on the configured API key. * - * @returns True if analytics should be initialized + * @returns `true` if the environment variable `EXPO_PUBLIC_AMPLITUDE_API_KEY` exists and is not empty after trimming, `false` otherwise. */ export function shouldInitializeAnalytics(): boolean { - // Native platforms use config files, not env vars - always initialize - // The native Firebase SDK will handle missing config gracefully - if (Platform.OS !== 'web') { - return true; - } - - // Web requires explicit configuration via environment variables - const measurementId = process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID?.trim(); - return Boolean(measurementId && measurementId.length > 0); + const apiKey = process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY?.trim(); + return Boolean(apiKey && apiKey.length > 0); } /** - * Checks if the app is running in debug mode. - * - * In debug mode, analytics events are logged to console - * and GA4 DebugView is enabled. + * Determines whether analytics should run in debug mode. * - * @returns True if in debug mode + * @returns `true` if `__DEV__` is truthy or `EXPO_PUBLIC_ANALYTICS_DEBUG` equals `'true'`, `false` otherwise. */ export function isDebugMode(): boolean { return __DEV__ || process.env.EXPO_PUBLIC_ANALYTICS_DEBUG === 'true'; } /** - * Gets the current environment for analytics tagging. + * Determine the analytics environment tag. * - * @returns The environment name + * @returns `'development'` when `__DEV__` is truthy; otherwise the value of `EXPO_PUBLIC_APP_ENV` if set, or `'production'`. */ export function getAnalyticsEnvironment(): string { if (__DEV__) return 'development'; diff --git a/lib/analytics/index.ts b/lib/analytics/index.ts index 162f04a4..f88ea5e0 100644 --- a/lib/analytics/index.ts +++ b/lib/analytics/index.ts @@ -1,5 +1,5 @@ /** - * Unified Firebase Analytics module for Sobers. + * Unified Amplitude Analytics module for Sobers. * * This is the ONLY module that app code should import for analytics. * Metro automatically selects the correct platform implementation: @@ -10,10 +10,10 @@ * * @example * ```ts - * import { trackEvent, setUserId, setUserProperties } from '@/lib/analytics'; + * import { trackEvent, setUserId, setUserProperties, AnalyticsEvents } from '@/lib/analytics'; * - * // Track an event - * trackEvent('task_completed', { task_id: '123' }); + * // Track an event using constants + * trackEvent(AnalyticsEvents.TASK_COMPLETED, { task_id: '123' }); * * // Set user ID after login * setUserId(user.id); @@ -23,8 +23,6 @@ * ``` */ -import { Platform } from 'react-native'; - import type { EventParams, UserProperties, AnalyticsConfig } from '@/types/analytics'; import { sanitizeParams, shouldInitializeAnalytics, isDebugMode } from '@/lib/analytics-utils'; import { logger, LogCategory } from '@/lib/logger'; @@ -41,7 +39,7 @@ import { // Re-export types and constants for convenience export { AnalyticsEvents, type AnalyticsEventName } from '@/types/analytics'; -export { calculateDaysSoberBucket } from '@/lib/analytics-utils'; +export { calculateDaysSoberBucket, calculateStepsCompletedBucket } from '@/lib/analytics-utils'; // ============================================================================= // Module State @@ -62,32 +60,20 @@ let initializationPromise: Promise | null = null; let initializationState: 'pending' | 'completed' | 'skipped' | 'failed' | null = null; /** - * Internal initialization logic. Called by initializeAnalytics wrapper. + * Initialize platform-specific analytics using the provided configuration. * - * @param config - Analytics configuration + * @param config - Analytics configuration used to initialize platform analytics (must include `apiKey`) */ async function doInitialize(config: AnalyticsConfig): Promise { await initializePlatformAnalytics(config); } /** - * Initialize Firebase Analytics for the app. - * - * Call this once at app startup, before any other analytics calls. - * On native platforms, Firebase is configured via config files. - * On web, it uses environment variables. + * Initialize Amplitude Analytics for the app. * - * This function uses a Promise-based pattern to prevent race conditions: - * - Concurrent calls during initialization will await the same Promise - * - Once completed, subsequent calls return immediately - * - On failure, retry is allowed (state is reset) + * Starts analytics initialization using EXPO_PUBLIC_AMPLITUDE_API_KEY. Concurrent callers will share the same initialization process; initialization is skipped if configuration indicates analytics should not run. Initialization failures are logged and do not throw, allowing retries on subsequent calls. * - * @example - * ```ts - * // In app/_layout.tsx - * import { initializeAnalytics } from '@/lib/analytics'; - * initializeAnalytics(); - * ``` + * @returns Nothing; resolves when initialization completes */ export async function initializeAnalytics(): Promise { // Already completed or skipped - return immediately @@ -114,7 +100,7 @@ export async function initializeAnalytics(): Promise { // Check if analytics should be initialized if (!shouldInitializeAnalytics()) { if (isDebugMode()) { - logger.warn('Firebase not configured - analytics disabled', { + logger.warn('Amplitude not configured - analytics disabled', { category: LogCategory.ANALYTICS, }); } @@ -122,26 +108,13 @@ export async function initializeAnalytics(): Promise { return; } - // On native, Firebase reads config from GoogleService-Info.plist / google-services.json - // On web, we need explicit configuration via environment variables + // Amplitude uses a single API key for all platforms + // Note: shouldInitializeAnalytics() already verified the key exists and is non-empty + // The fallback handles test environments where the mock bypasses the check const config: AnalyticsConfig = { - apiKey: process.env.EXPO_PUBLIC_FIREBASE_API_KEY || '', - projectId: process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID || '', - appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID || '', - measurementId: process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID || '', + apiKey: process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY?.trim() ?? '', }; - // Only warn about missing config on web - native uses config files, not env vars - if (Platform.OS === 'web' && (!config.apiKey || !config.projectId || !config.appId)) { - logger.warn('Firebase config incomplete - some required values are missing', { - category: LogCategory.ANALYTICS, - hasApiKey: !!config.apiKey, - hasProjectId: !!config.projectId, - hasAppId: !!config.appId, - hasMeasurementId: !!config.measurementId, - }); - } - // Start new initialization initializationState = 'pending'; initializationPromise = doInitialize(config) diff --git a/lib/analytics/platform.native.ts b/lib/analytics/platform.native.ts index 4e10d99e..df296084 100644 --- a/lib/analytics/platform.native.ts +++ b/lib/analytics/platform.native.ts @@ -1,312 +1,168 @@ +// lib/analytics/platform.native.ts /** - * Firebase Analytics implementation for native platforms (iOS/Android). + * Amplitude Analytics implementation for native platforms (iOS/Android). * * This file is automatically selected by Metro bundler on iOS and Android. - * Uses the modular API introduced in React Native Firebase v22. * * @module lib/analytics/platform.native - * @see {@link https://rnfirebase.io/migrating-to-v22 Migration Guide} */ -import { - getAnalytics, - logEvent, - setUserId, - setUserProperties, - resetAnalyticsData, - setAnalyticsCollectionEnabled, -} from '@react-native-firebase/analytics'; +import * as amplitude from '@amplitude/analytics-react-native'; -import type { EventParams, UserProperties, AnalyticsConfig } from '@/types/analytics'; +import { + AnalyticsEvents, + type EventParams, + type UserProperties, + type AnalyticsConfig, +} from '@/types/analytics'; import { isDebugMode } from '@/lib/analytics-utils'; import { logger, LogCategory } from '@/lib/logger'; -// Lazily initialized analytics instance to avoid module-scope crashes -// when Firebase config files are missing -let analyticsInstance: ReturnType | null = null; +let isInitialized = false; /** - * Gets or initializes the Firebase Analytics instance. + * Initialize Amplitude analytics with the provided configuration for native platforms. * - * Deferred initialization prevents crashes when Firebase config files - * (GoogleService-Info.plist / google-services.json) are missing. + * If analytics have already been initialized this function is a no-op. * - * @returns The analytics instance, or null if initialization fails + * @param config - Configuration containing the Amplitude `apiKey` used to initialize the SDK + * @throws An `Error` if Amplitude initialization fails */ -function getAnalyticsInstance(): ReturnType | null { - if (analyticsInstance === null) { - try { - analyticsInstance = getAnalytics(); - } catch (error) { - logger.error( - 'Failed to get Firebase Analytics instance', - error instanceof Error ? error : new Error(String(error)), - { category: LogCategory.ANALYTICS } - ); - return null; +export async function initializePlatformAnalytics(config: AnalyticsConfig): Promise { + if (isInitialized) { + if (isDebugMode()) { + logger.debug('Amplitude already initialized', { category: LogCategory.ANALYTICS }); } + return; } - return analyticsInstance; -} -/** - * Initializes Firebase Analytics for native platforms using the bundled native configuration. - * - * The optional `_config` argument is ignored on iOS and Android. - * - * @param _config - Optional analytics configuration; ignored on native platforms - */ -export async function initializePlatformAnalytics(_config?: AnalyticsConfig): Promise { try { - const analytics = getAnalyticsInstance(); - if (!analytics) { - logger.warn('Analytics not available - Firebase may not be configured', { - category: LogCategory.ANALYTICS, - }); - return; - } + await amplitude.init(config.apiKey, undefined, { + logLevel: isDebugMode() ? amplitude.Types.LogLevel.Debug : amplitude.Types.LogLevel.None, + }).promise; - // Always enable analytics collection (on native, it's enabled by default, - // but we explicitly enable it to ensure consistent behavior) - await setAnalyticsCollectionEnabled(analytics, true); + isInitialized = true; if (isDebugMode()) { - logger.info('Firebase Analytics initialized for native', { + logger.info('Amplitude Analytics initialized for native', { category: LogCategory.ANALYTICS, }); } } catch (error) { - logger.error( - 'Failed to initialize native analytics', - error instanceof Error ? error : new Error(String(error)), - { category: LogCategory.ANALYTICS } - ); + const err = error instanceof Error ? error : new Error(String(error)); + logger.error('Failed to initialize Amplitude', err, { category: LogCategory.ANALYTICS }); + throw err; // Rethrow so caller can handle and allow retry } } /** - * Tracks an analytics event. + * Record an analytics event with optional parameters. * - * This function is synchronous (fire-and-forget) to match the public API in index.ts. - * Errors are caught and logged but not propagated to avoid unhandled promise rejections. + * If analytics has not been initialized this function is a no-op. Any runtime + * errors encountered while sending the event are logged and not thrown. * - * @param eventName - The analytics event name to record - * @param params - Optional event parameters as key/value pairs + * @param eventName - The name of the event to record + * @param params - Optional key/value parameters to attach to the event */ export function trackEventPlatform(eventName: string, params?: EventParams): void { if (isDebugMode()) { - const { error_message, error_stack, error_name, ...safeParams } = params || {}; - logger.debug(`Event: ${eventName}`, { category: LogCategory.ANALYTICS, ...safeParams }); + logger.debug(`Event: ${eventName}`, { category: LogCategory.ANALYTICS, ...params }); } - const analytics = getAnalyticsInstance(); - if (!analytics) return; + if (!isInitialized) return; - logEvent(analytics, eventName, params).catch((error: unknown) => { + try { + amplitude.track(eventName, params); + } catch (error) { logger.error( - `Failed to track event ${eventName}`, + 'Failed to track event', error instanceof Error ? error : new Error(String(error)), - { category: LogCategory.ANALYTICS } + { category: LogCategory.ANALYTICS, eventName } ); - }); -} - -/** - * Computes a simple deterministic hash of a string for privacy-safe logging. - * - * Uses a djb2-style hash algorithm that's fast and deterministic. - * This is suitable for logging purposes (not cryptographic security). - * - * @param input - The string to hash - * @returns The hex-encoded hash, or null if input is null/undefined - * - * @example - * ```ts - * const hash = hashUserIdForLogging('user-123'); - * // Returns: 'a1b2c3d4...' (deterministic hash) - * ``` - */ -function hashUserIdForLogging(input: string | null): string | null { - if (input === null || input === undefined) { - return null; } - - // Simple djb2-style hash for deterministic, one-way hashing - let hash = 5381; - for (let i = 0; i < input.length; i++) { - hash = (hash << 5) + hash + input.charCodeAt(i); - hash = hash | 0; // Convert to 32-bit integer - } - - // Convert to positive hex string (8 chars for 32-bit hash) - const hashHex = Math.abs(hash).toString(16).padStart(8, '0'); - return hashHex; } /** - * Set the analytics user ID for the current session. + * Set the analytics user identifier for the current session. * - * Passing `null` clears the current user ID. Errors encountered while setting the ID - * are caught and logged and will not be propagated. - * - * @param userId - The user ID to set, or `null` to clear the current user ID + * @param userId - The user identifier to assign; pass `null` to clear the current user id */ export function setUserIdPlatform(userId: string | null): void { if (isDebugMode()) { - const hashed = hashUserIdForLogging(userId); - if (hashed === null) { - logger.debug('setUserId: null', { category: LogCategory.ANALYTICS }); - } else { - logger.debug(`setUserId: `, { category: LogCategory.ANALYTICS }); - } + logger.debug(`setUserId: ${userId ? '' : 'null'}`, { category: LogCategory.ANALYTICS }); } - const analytics = getAnalyticsInstance(); - if (!analytics) return; + if (!isInitialized) return; - setUserId(analytics, userId).catch((error: unknown) => { + try { + amplitude.setUserId(userId ?? undefined); + } catch (error) { logger.error( 'Failed to set user ID', error instanceof Error ? error : new Error(String(error)), { category: LogCategory.ANALYTICS } ); - }); -} - -/** - * Sanitizes user properties for safe logging by redacting sensitive values. - * - * @param properties - User properties to sanitize - * @returns Sanitized properties with sensitive values replaced with "" - */ -function sanitizeUserPropertiesForLogging( - properties: UserProperties -): Record { - const sensitiveKeys = new Set([ - 'email', - 'name', - 'phone', - 'phone_number', - 'display_name', - 'full_name', - 'first_name', - 'last_name', - 'user_name', - 'username', - ]); - - const sanitized: Record = {}; - for (const [key, value] of Object.entries(properties)) { - if (value === undefined) continue; - if (sensitiveKeys.has(key.toLowerCase())) { - sanitized[key] = ''; - } else if (value === null) { - sanitized[key] = null; - } else if (typeof value === 'boolean') { - sanitized[key] = value ? 'true' : 'false'; - } else { - sanitized[key] = String(value); - } } - return sanitized; } /** - * Set analytics user properties for the native platform. + * Apply a set of user properties to the current analytics user via Amplitude Identify. * - * Accepts an object mapping property names to values; `null` clears a property and `undefined` entries are ignored. - * Boolean values are converted to the strings `'true'` or `'false'` to meet Firebase requirements; other non-null values are converted to strings. - * Errors from the underlying analytics call are logged and not rethrown. + * Properties with the value `undefined` are ignored. If analytics has not been initialized, this is a no-op. Errors are caught and logged and will not be thrown to the caller. * - * @param properties - Mapping of user property names to string, boolean, `null`, or `undefined` values + * @param properties - Key/value map of user properties to set; values other than `undefined` will be written to the analytics user profile */ export function setUserPropertiesPlatform(properties: UserProperties): void { if (isDebugMode()) { - const sanitized = sanitizeUserPropertiesForLogging(properties); - logger.debug('setUserProperties', { category: LogCategory.ANALYTICS, ...sanitized }); - } - - // Convert boolean values to strings for Firebase compatibility - const normalized: Record = {}; - for (const [key, value] of Object.entries(properties)) { - if (value === undefined) continue; - if (value === null) { - normalized[key] = null; - } else if (typeof value === 'boolean') { - normalized[key] = value ? 'true' : 'false'; - } else { - normalized[key] = String(value); - } + logger.debug('setUserProperties', { category: LogCategory.ANALYTICS, ...properties }); } - const analytics = getAnalyticsInstance(); - if (!analytics) return; + if (!isInitialized) return; - setUserProperties(analytics, normalized).catch((error: unknown) => { + try { + const identify = new amplitude.Identify(); + for (const [key, value] of Object.entries(properties)) { + if (value !== undefined) { + identify.set(key, value); + } + } + amplitude.identify(identify); + } catch (error) { logger.error( 'Failed to set user properties', error instanceof Error ? error : new Error(String(error)), { category: LogCategory.ANALYTICS } ); - }); + } } /** - * Record a screen view in analytics. - * - * This is a fire-and-forget call: it enqueues a 'screen_view' event and catches/logs any errors without throwing. - * - * @param screenName - The displayed name of the screen to record - * @param screenClass - Optional screen class; defaults to `screenName` when omitted + * Tracks a screen view event. + * @param screenName - The name of the screen being viewed + * @param screenClass - Optional screen class. Defaults to screenName if not provided. */ export function trackScreenViewPlatform(screenName: string, screenClass?: string): void { - if (isDebugMode()) { - logger.debug(`Screen view: ${screenName}`, { category: LogCategory.ANALYTICS }); - } - - const analytics = getAnalyticsInstance(); - if (!analytics) return; - - // Use logEvent with 'screen_view' instead of the deprecated logScreenView function - // Type assertion needed because Firebase typings have strict overloads - ( - logEvent as ( - analytics: ReturnType, - event: string, - params?: Record - ) => Promise - )(analytics, 'screen_view', { + trackEventPlatform(AnalyticsEvents.SCREEN_VIEWED, { screen_name: screenName, screen_class: screenClass || screenName, - }).catch((error: unknown) => { - logger.error( - 'Failed to track screen view', - error instanceof Error ? error : new Error(String(error)), - { category: LogCategory.ANALYTICS } - ); }); } /** - * Clear the native Firebase Analytics instance state and stored analytics data, typically used on user logout. + * Reset analytics state for the native Amplitude integration. * - * If Firebase Analytics is unavailable, logs a warning and returns without error. Any errors encountered while resetting are logged and not rethrown. + * If analytics have not been initialized this function is a no-op. Any errors + * encountered while resetting are logged and not rethrown. */ export async function resetAnalyticsPlatform(): Promise { - try { - const analytics = getAnalyticsInstance(); - if (!analytics) { - logger.warn('Cannot reset analytics - Firebase not available', { - category: LogCategory.ANALYTICS, - }); - return; - } + if (isDebugMode()) { + logger.info('Resetting analytics state', { category: LogCategory.ANALYTICS }); + } - if (isDebugMode()) { - logger.info('Resetting analytics state', { category: LogCategory.ANALYTICS }); - } + if (!isInitialized) return; - await resetAnalyticsData(analytics); + try { + await amplitude.reset(); } catch (error) { logger.error( 'Failed to reset analytics', @@ -315,3 +171,18 @@ export async function resetAnalyticsPlatform(): Promise { ); } } + +/** + * Reset the module's initialization state for test environments. + * + * Sets the internal initialized flag to `false`. This function is intended for use only during tests. + * + * @internal + * @throws Error If called when NODE_ENV is not `'test'`. + */ +export function __resetForTesting(): void { + if (process.env.NODE_ENV !== 'test') { + throw new Error('__resetForTesting should only be called in test environments'); + } + isInitialized = false; +} diff --git a/lib/analytics/platform.web.ts b/lib/analytics/platform.web.ts index 4497ebf8..ceac882d 100644 --- a/lib/analytics/platform.web.ts +++ b/lib/analytics/platform.web.ts @@ -1,448 +1,102 @@ +// lib/analytics/platform.web.ts /** - * Firebase Analytics implementation for web platform. + * Amplitude Analytics implementation for web platform. * * This file is automatically selected by Metro bundler on web. - * Uses Firebase Analytics exclusively for consistency with native platforms. * * @module lib/analytics/platform.web */ -// ============================================================================= -// Imports -// ============================================================================= -import { initializeApp, getApps, getApp, FirebaseApp } from 'firebase/app'; -import { - getAnalytics, - logEvent, - setUserId as firebaseSetUserId, - setUserProperties as firebaseSetUserProperties, - isSupported, - Analytics, -} from 'firebase/analytics'; +import * as amplitude from '@amplitude/analytics-browser'; -import type { EventParams, UserProperties, AnalyticsConfig } from '@/types/analytics'; +import { + AnalyticsEvents, + type EventParams, + type UserProperties, + type AnalyticsConfig, +} from '@/types/analytics'; import { isDebugMode } from '@/lib/analytics-utils'; import { logger, LogCategory } from '@/lib/logger'; -// ============================================================================= -// Types & Interfaces -// ============================================================================= -// No additional types needed - Firebase types imported above - -// ============================================================================= -// Constants -// ============================================================================= -// Firebase Analytics is always enabled on web - -// ============================================================================= -// Module State -// ============================================================================= -let analytics: Analytics | null = null; -let app: FirebaseApp | null = null; +let isInitialized = false; /** - * Initialization state to prevent race conditions. - * - * Uses Promise-based pattern instead of boolean flag: - * - null: not started - * - Promise: initialization in progress (concurrent callers await the same Promise) - * - 'completed': successfully initialized + * Initialize Amplitude for the web platform using the provided analytics configuration. * - * This prevents the race condition where resetting a boolean flag on failure - * could allow concurrent calls to both proceed with initialization. - */ -let initializationPromise: Promise | null = null; -let initializationState: 'pending' | 'completed' | 'failed' | null = null; - -// ============================================================================= -// Constants (continued) -// ============================================================================= -/** - * Reserved keys in logger metadata that should not be used. - * These keys are used internally by the logger for error information. - * Note: 'category' is extracted separately and not a reserved key. - */ -const LOGGER_RESERVED_KEYS = ['error_message', 'error_stack', 'error_name'] as const; - -/** - * Whitelist of safe, non-PII keys from UserProperties that can be logged. - * This ensures we only log analytics metadata, not sensitive user data. - */ -const SAFE_USER_PROPERTY_KEYS: readonly (keyof UserProperties)[] = [ - 'days_sober_bucket', - 'has_sponsor', - 'has_sponsees', - 'theme_preference', - 'sign_in_method', -] as const; - -// Note: LOGGER_RESERVED_KEYS (defined above) is used for both purposes: -// sanitizing user properties and sanitizing event params. - -/** - * PII-prone keys that should be redacted from logs. - * These keys commonly contain sensitive user information. - */ -const PII_KEYS = [ - 'email', - 'phone', - 'phone_number', - 'full_name', - 'name', - 'user_name', - 'username', - 'password', - 'token', - 'access_token', - 'refresh_token', -] as const; - -// ============================================================================= -// Helper Functions -// ============================================================================= - -/** - * Sanitizes UserProperties for safe logging by: - * 1. Whitelisting only safe, non-PII keys - * 2. Filtering out undefined values - * 3. Prefixing any keys that conflict with logger reserved keys - * 4. Masking any potentially sensitive values (defensive) + * This is a no-op if analytics are already initialized. On success it marks the module as initialized. * - * @param properties - Raw user properties to sanitize - * @returns Sanitized metadata object safe for logger + * @param config - Analytics configuration; `config.apiKey` is used to initialize Amplitude. + * @throws Error if Amplitude initialization fails. */ -function sanitizeUserPropertiesForLogging(properties: UserProperties): Record { - const sanitized: Record = {}; - - for (const key of SAFE_USER_PROPERTY_KEYS) { - const value = properties[key]; - if (value === undefined) continue; - - // Check for reserved key conflicts and prefix if needed - const safeKey = LOGGER_RESERVED_KEYS.includes(key as (typeof LOGGER_RESERVED_KEYS)[number]) - ? `analytics_${key}` - : key; - - // All current UserProperties values are safe (strings or booleans), - // but we're defensive and don't log raw values that might contain PII - sanitized[safeKey] = value; - } - - return sanitized; -} - -/** - * Converts UserProperties to Firebase Analytics-compatible format. - * Firebase web SDK requires Record (no undefined or null). - * - * @param properties - User properties to convert - * @returns Firebase-compatible properties object - */ -function convertToFirebaseUserProperties( - properties: UserProperties -): Record { - const firebaseProperties: Record = {}; - - for (const [key, value] of Object.entries(properties)) { - // Skip undefined values (Firebase doesn't accept them) - if (value === undefined) continue; - - // Keep booleans as booleans (Firebase accepts boolean) - if (typeof value === 'boolean') { - firebaseProperties[key] = value; - } - // Convert strings (Firebase accepts string) - else if (typeof value === 'string') { - firebaseProperties[key] = value; +export async function initializePlatformAnalytics(config: AnalyticsConfig): Promise { + if (isInitialized) { + if (isDebugMode()) { + logger.debug('Amplitude already initialized', { category: LogCategory.ANALYTICS }); } - // Drop any other types (shouldn't happen with current UserProperties type) + return; } - return firebaseProperties; -} + try { + await amplitude.init(config.apiKey, undefined, { + logLevel: isDebugMode() ? amplitude.Types.LogLevel.Debug : amplitude.Types.LogLevel.None, + }).promise; -/** - * Computes a SHA-256 hash of a string for privacy-safe logging. - * - * @param input - The string to hash - * @returns Promise that resolves to the hex-encoded hash, or null if input is null/undefined - * - * @example - * ```ts - * const hash = await hashUserId('user-123'); - * // Returns: 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3' - * ``` - */ -async function hashUserId(input: string | null): Promise { - if (input === null || input === undefined) { - return null; - } + isInitialized = true; - try { - const encoder = new TextEncoder(); - const data = encoder.encode(input); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - return hashHex; + if (isDebugMode()) { + logger.info('Amplitude Analytics initialized for web', { + category: LogCategory.ANALYTICS, + }); + } } catch (error) { - // Fallback: if crypto API fails, return a placeholder - logger.warn('Failed to hash userId for logging', { - category: LogCategory.ANALYTICS, - hashError: error instanceof Error ? error.message : String(error), - }); - return '[hash-error]'; + const err = error instanceof Error ? error : new Error(String(error)); + logger.error('Failed to initialize Amplitude', err, { category: LogCategory.ANALYTICS }); + throw err; // Rethrow so caller can handle and allow retry } } /** - * Create a logger-safe copy of event parameters with reserved keys removed and sensitive values redacted. + * Sends an analytics event to Amplitude when the analytics system has been initialized. * - * @param ancestors - WeakSet used to track objects in the current recursion path to detect circular references - * @returns An object suitable for logging where logger-reserved keys are omitted, PII-prone values are replaced with `"[Filtered]"`, and circular references are marked `"[Circular]"` + * @param eventName - The event name to record + * @param params - Optional event parameters/properties to attach to the event */ -function sanitizeParamsForLogging( - params?: EventParams, - ancestors: WeakSet = new WeakSet() -): Record { - if (!params) { - return {}; - } - - const sanitized: Record = {}; - - for (const [key, value] of Object.entries(params)) { - // Skip reserved logger keys to prevent overwrites - if (LOGGER_RESERVED_KEYS.includes(key as (typeof LOGGER_RESERVED_KEYS)[number])) { - continue; - } - - // Redact PII-prone keys - if (PII_KEYS.includes(key.toLowerCase() as (typeof PII_KEYS)[number])) { - sanitized[key] = '[Filtered]'; - continue; - } - - // For nested objects, recursively sanitize with circular reference detection - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - // Detect circular references (object is an ancestor in current recursion path) - if (ancestors.has(value)) { - sanitized[key] = '[Circular]'; - continue; - } - // Add to ancestors for this recursion path, then remove after processing - ancestors.add(value); - sanitized[key] = sanitizeParamsForLogging(value as EventParams, ancestors); - ancestors.delete(value); - } else { - sanitized[key] = value; - } +export function trackEventPlatform(eventName: string, params?: EventParams): void { + if (isDebugMode()) { + logger.debug(`Event: ${eventName}`, { category: LogCategory.ANALYTICS, ...params }); } - return sanitized; -} -/** - * Sends an event to Firebase Analytics. - * - * If the analytics instance has not been initialized the call returns without sending. - * SDK errors are caught and logged under the `ANALYTICS` category. - * - * @param eventName - The name of the event - * @param params - Optional event parameters - */ -function dispatchEvent(eventName: string, params?: EventParams): void { - if (!analytics) { - return; - } + if (!isInitialized) return; try { - logEvent(analytics, eventName, params); + amplitude.track(eventName, params); } catch (error) { logger.error( - `Failed to track event ${eventName} in Firebase`, + 'Failed to track event', error instanceof Error ? error : new Error(String(error)), - { category: LogCategory.ANALYTICS } + { category: LogCategory.ANALYTICS, eventName } ); } } -// ============================================================================= -// Main Logic -// ============================================================================= - -/** - * Initialize Firebase Analytics for the web if supported and not already initialized. - * - * Attempts to obtain or create a Firebase app and Analytics instance using the provided config. - * If the runtime does not support Firebase Analytics, this function performs no initialization. - * Initialization errors are logged for monitoring and do not propagate to the caller. - * - * @param config - Firebase configuration (apiKey, projectId, appId, measurementId) - */ -async function doInitialize(config: AnalyticsConfig): Promise { - try { - const supported = await isSupported(); - if (!supported) { - if (isDebugMode()) { - logger.warn('Firebase Analytics not supported in this browser', { - category: LogCategory.ANALYTICS, - }); - } - return; - } - - if (getApps().length > 0) { - // Retrieve existing app and analytics instance - app = getApp(); - analytics = getAnalytics(app); - if (isDebugMode()) { - logger.info('Firebase app already initialized, retrieved existing instance', { - category: LogCategory.ANALYTICS, - }); - } - } else { - app = initializeApp({ - apiKey: config.apiKey, - projectId: config.projectId, - appId: config.appId, - measurementId: config.measurementId, - }); - - analytics = getAnalytics(app); - - if (isDebugMode()) { - logger.info('Firebase Analytics initialized for web', { - category: LogCategory.ANALYTICS, - }); - } - } - } catch (error) { - const firebaseError = error instanceof Error ? error : new Error(String(error)); - logger.error('Failed to initialize Firebase Analytics', firebaseError, { - category: LogCategory.ANALYTICS, - }); - } - - // Note: We intentionally do NOT throw on Firebase failures. - // Analytics initialization failures should be handled gracefully since they're - // not critical to app functionality. The error is logged above for monitoring. -} - /** - * Initialize Firebase Analytics for the web platform and manage a single shared initialization lifecycle. + * Set the current analytics user identifier. * - * Concurrent callers will share the same in-progress initialization; once initialization completes - * subsequent calls return immediately. If initialization fails the state is reset to allow retries. + * If `userId` is `null` the analytics user identifier is cleared. If the analytics + * subsystem has not been initialized this function is a no-op; errors during the + * underlying SDK call are caught and logged. * - * @param config - Firebase configuration; missing or invalid fields (e.g., empty apiKey, projectId, or appId) may cause initialization to fail and will be logged. - */ -export async function initializePlatformAnalytics(config: AnalyticsConfig): Promise { - // Already completed successfully - return immediately - if (initializationState === 'completed') { - if (isDebugMode()) { - logger.debug('Analytics already initialized, skipping re-initialization', { - category: LogCategory.ANALYTICS, - }); - } - return; - } - - // Initialization in progress - wait for the existing Promise - // This prevents race conditions from concurrent calls - if (initializationPromise !== null && initializationState === 'pending') { - if (isDebugMode()) { - logger.debug('Analytics initialization in progress, waiting...', { - category: LogCategory.ANALYTICS, - }); - } - return initializationPromise; - } - - // Start new initialization - initializationState = 'pending'; - initializationPromise = doInitialize(config) - .then(() => { - initializationState = 'completed'; - }) - .catch((error) => { - // Reset state to allow retry - initializationState = 'failed'; - initializationPromise = null; - throw error; - }); - - return initializationPromise; -} - -/** - * Record an analytics event with optional parameters; when initialized the event is sent to Firebase Analytics. - * - * If analytics is not initialized the event is not sent. In debug mode the event and sanitized parameters are logged. - * - * @param eventName - The name of the event - * @param params - Optional event parameters - */ -export function trackEventPlatform(eventName: string, params?: EventParams): void { - if (!analytics) { - if (isDebugMode()) { - const sanitizedParams = sanitizeParamsForLogging(params); - logger.debug(`Event (not sent - no provider initialized): ${eventName}`, { - category: LogCategory.ANALYTICS, - provider: 'firebase', - event_params: sanitizedParams, - }); - } - return; - } - - if (isDebugMode()) { - const sanitizedParams = sanitizeParamsForLogging(params); - logger.debug(`Event: ${eventName}`, { - category: LogCategory.ANALYTICS, - event_params: sanitizedParams, - }); - } - - dispatchEvent(eventName, params); -} - -/** - * Set the analytics user identifier used by Firebase; pass `null` to clear it. - * - * @param userId - The user identifier to set, or `null` to clear the current user ID + * @param userId - The user identifier to set, or `null` to clear the identifier */ export function setUserIdPlatform(userId: string | null): void { - if (!analytics) { - if (isDebugMode()) { - // Hash userId for privacy-safe logging (fire-and-forget) - hashUserId(userId).then((hashed) => { - if (hashed === null) { - logger.debug('setUserId (not sent - Firebase not initialized): null', { - category: LogCategory.ANALYTICS, - }); - } else { - logger.debug(`setUserId (not sent - Firebase not initialized): `, { - category: LogCategory.ANALYTICS, - }); - } - }); - } - return; - } - if (isDebugMode()) { - // Hash userId for privacy-safe logging (fire-and-forget) - hashUserId(userId).then((hashed) => { - if (hashed === null) { - logger.debug('setUserId: null', { category: LogCategory.ANALYTICS }); - } else { - logger.debug(`setUserId: `, { category: LogCategory.ANALYTICS }); - } - }); + logger.debug(`setUserId: ${userId ? '' : 'null'}`, { category: LogCategory.ANALYTICS }); } + if (!isInitialized) return; + try { - firebaseSetUserId(analytics, userId); + amplitude.setUserId(userId ?? undefined); } catch (error) { logger.error( 'Failed to set user ID', @@ -453,37 +107,25 @@ export function setUserIdPlatform(userId: string | null): void { } /** - * Set user properties on the analytics backend. + * Update the current user's analytics properties, applying only keys with defined values. * - * Sanitizes and sends the provided user properties to Firebase Analytics. Undefined values are ignored and only string or boolean property values are applied. If the analytics subsystem is not initialized, the properties are not sent. - * - * @param properties - User properties to apply; undefined values are ignored and only string or boolean values will be forwarded to Firebase + * @param properties - Mapping of user property names to values; properties with value `undefined` are ignored. */ export function setUserPropertiesPlatform(properties: UserProperties): void { - // Sanitize properties for safe logging (whitelist non-PII, avoid reserved keys) - const sanitizedMetadata = sanitizeUserPropertiesForLogging(properties); - - if (!analytics) { - if (isDebugMode()) { - logger.debug('setUserProperties (not sent - Firebase not initialized)', { - category: LogCategory.ANALYTICS, - user_properties: sanitizedMetadata, - }); - } - return; - } - if (isDebugMode()) { - logger.debug('setUserProperties', { - category: LogCategory.ANALYTICS, - user_properties: sanitizedMetadata, - }); + logger.debug('setUserProperties', { category: LogCategory.ANALYTICS, ...properties }); } + if (!isInitialized) return; + try { - // Convert to Firebase-compatible format (filters undefined, keeps string|boolean) - const firebaseProperties = convertToFirebaseUserProperties(properties); - firebaseSetUserProperties(analytics, firebaseProperties); + const identify = new amplitude.Identify(); + for (const [key, value] of Object.entries(properties)) { + if (value !== undefined) { + identify.set(key, value); + } + } + amplitude.identify(identify); } catch (error) { logger.error( 'Failed to set user properties', @@ -495,53 +137,48 @@ export function setUserPropertiesPlatform(properties: UserProperties): void { /** * Tracks a screen view event. - * - * @param screenName - The name of the screen - * @param screenClass - Optional screen class name + * @param screenName - The name of the screen being viewed + * @param screenClass - Optional screen class. Defaults to screenName if not provided. */ export function trackScreenViewPlatform(screenName: string, screenClass?: string): void { - trackEventPlatform('screen_view', { + trackEventPlatform(AnalyticsEvents.SCREEN_VIEWED, { screen_name: screenName, screen_class: screenClass || screenName, }); } /** - * Reset analytics state for the current user. + * Reset analytics client state for the web platform. * - * Clears the Firebase Analytics user ID. + * This is a no-op when analytics has not been initialized. Errors that occur during reset are logged and not rethrown. */ export async function resetAnalyticsPlatform(): Promise { if (isDebugMode()) { logger.info('Resetting analytics state', { category: LogCategory.ANALYTICS }); } - setUserIdPlatform(null); + if (!isInitialized) return; + + try { + amplitude.reset(); + } catch (error) { + logger.error( + 'Failed to reset analytics', + error instanceof Error ? error : new Error(String(error)), + { category: LogCategory.ANALYTICS } + ); + } } -// ============================================================================= -// Testing Utilities -// ============================================================================= /** - * Reset the module's analytics initialization state for tests. + * Reset module initialization state for tests. * - * Clears the internal initialization state and removes stored Firebase analytics - * instance so the module can be re-initialized in a test. - * - * @throws Will throw an Error if called when NODE_ENV is not 'test'. * @internal + * @throws Error if called outside a test environment (NODE_ENV !== 'test') */ export function __resetForTesting(): void { if (process.env.NODE_ENV !== 'test') { throw new Error('__resetForTesting should only be called in test environments'); } - initializationState = null; - initializationPromise = null; - analytics = null; - app = null; + isInitialized = false; } - -// ============================================================================= -// Exports -// ============================================================================= -// All exports are defined above with their implementations diff --git a/package.json b/package.json index 338ada24..ede2f460 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", + "spotlight": "spotlight-sidecar", "build:web": "expo export --platform web", "build:android": "expo export --platform android", "build:ios": "expo export --platform ios", @@ -33,6 +34,8 @@ "prepare": "husky" }, "dependencies": { + "@amplitude/analytics-browser": "^2.33.0", + "@amplitude/analytics-react-native": "^1.5.32", "@bottom-tabs/react-navigation": "^1.1.0", "@date-fns/tz": "^1.4.1", "@expo-google-fonts/jetbrains-mono": "^0.4.1", @@ -40,8 +43,6 @@ "@gorhom/bottom-sheet": "^5.2.8", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/datetimepicker": "^8.4.4", - "@react-native-firebase/analytics": "^23.7.0", - "@react-native-firebase/app": "^23.7.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.9.3", "@react-navigation/native": "^7.1.8", @@ -72,7 +73,6 @@ "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", "expo-web-browser": "~15.0.10", - "firebase": "^12.7.0", "lucide-react-native": "^0.562.0", "react": "19.1.0", "react-dom": "19.1.0", @@ -93,6 +93,7 @@ }, "devDependencies": { "@playwright/test": "^1.57.0", + "@spotlightjs/spotlight": "^4.9.0", "@testing-library/react-native": "^13.3.3", "@types/jest": "^29.5.14", "@types/react": "~19.1.17", diff --git a/plugins/withFirebaseConfig.js b/plugins/withFirebaseConfig.js deleted file mode 100644 index f409cb03..00000000 --- a/plugins/withFirebaseConfig.js +++ /dev/null @@ -1,250 +0,0 @@ -/* eslint-disable no-console */ -/** - * Expo config plugin to inject Firebase configuration files from EAS Secrets. - * - * This plugin reads Firebase config from EAS file secrets and writes them to - * the correct locations during prebuild. - * - * @remarks - * For local development, place the files directly in the project root. - * - * For EAS builds, create file secrets: - * eas secret:create --scope project --name GOOGLE_SERVICES_JSON --type file --value ./google-services.json - * eas secret:create --scope project --name GOOGLE_SERVICE_INFO_PLIST --type file --value ./GoogleService-Info.plist - * - * @see {@link https://docs.expo.dev/build-reference/variables/#using-secrets-in-environment-variables EAS Secrets} - */ -const { withDangerousMod } = require('expo/config-plugins'); -const fs = require('fs'); -const path = require('path'); -const { Buffer } = require('buffer'); - -/** - * Validates if a string is valid JSON. - * - * @param {string} content - The content to validate - * @returns {boolean} True if valid JSON - */ -function isValidJson(content) { - try { - JSON.parse(content); - return true; - } catch { - return false; - } -} - -/** - * Validates if a string is valid plist XML. - * Checks for proper plist structure with opening and closing tags. - * - * @param {string} content - The content to validate - * @returns {boolean} True if valid plist structure - */ -function isValidPlist(content) { - const trimmed = content.trim(); - // Check for XML declaration or plist DOCTYPE, and proper plist tags - const hasXmlOrDoctype = trimmed.startsWith(''); - - // Must have plist tags and either start with XML declaration/DOCTYPE or { - const projectRoot = config.modRequest.projectRoot; - const androidAppDir = path.join(projectRoot, 'android', 'app'); - const targetPath = path.join(androidAppDir, 'google-services.json'); - - // Check for EAS secret first - const secretValue = process.env.GOOGLE_SERVICES_JSON; - if (secretValue && writeFromSecret(secretValue, targetPath)) { - console.log('✓ Wrote google-services.json from EAS secret'); - return config; - } - - // Fall back to local file (development) - const localFile = path.join(projectRoot, 'google-services.json'); - if (fs.existsSync(localFile)) { - if (!fs.existsSync(androidAppDir)) { - fs.mkdirSync(androidAppDir, { recursive: true }); - } - fs.copyFileSync(localFile, targetPath); - console.log('✓ Copied google-services.json from project root'); - return config; - } - - console.warn( - '⚠ No google-services.json found. Set GOOGLE_SERVICES_JSON secret or place file in project root.' - ); - return config; - }, - ]); -} - -/** - * Ensures an iOS Firebase config file (GoogleService-Info.plist) is present in the app project. - * - * Attempts to write the plist into ios//GoogleService-Info.plist from the - * EAS secret `GOOGLE_SERVICE_INFO_PLIST`, falling back to copying a local - * GoogleService-Info.plist from the project root if the secret is not provided. - * - * @param {import('expo/config-plugins').ConfigPlugin} config - Expo config to modify. - * @returns {import('expo/config-plugins').ConfigPlugin} The updated Expo config. - */ -function withIosFirebaseConfig(config) { - return withDangerousMod(config, [ - 'ios', - async (config) => { - const projectRoot = config.modRequest.projectRoot; - const projectName = config.modRequest.projectName || config.name; - const iosAppDir = path.join(projectRoot, 'ios', projectName); - const targetPath = path.join(iosAppDir, 'GoogleService-Info.plist'); - - // Check for EAS secret first - const secretValue = process.env.GOOGLE_SERVICE_INFO_PLIST; - if (secretValue && writeFromSecret(secretValue, targetPath)) { - console.log('✓ Wrote GoogleService-Info.plist from EAS secret'); - return config; - } - - // Fall back to local file (development) - const localFile = path.join(projectRoot, 'GoogleService-Info.plist'); - if (fs.existsSync(localFile)) { - if (!fs.existsSync(iosAppDir)) { - fs.mkdirSync(iosAppDir, { recursive: true }); - } - fs.copyFileSync(localFile, targetPath); - console.log('✓ Copied GoogleService-Info.plist from project root'); - return config; - } - - console.warn( - '⚠ No GoogleService-Info.plist found. Set GOOGLE_SERVICE_INFO_PLIST secret or place file in project root.' - ); - return config; - }, - ]); -} - -/** - * Combined plugin that handles both platforms. - */ -function withFirebaseConfig(config) { - config = withAndroidFirebaseConfig(config); - config = withIosFirebaseConfig(config); - return config; -} - -module.exports = withFirebaseConfig; diff --git a/plugins/withModularHeaders.js b/plugins/withModularHeaders.js deleted file mode 100644 index 31c54ffe..00000000 --- a/plugins/withModularHeaders.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Expo config plugin to add `use_modular_headers!` to the iOS Podfile. - * - * @remarks - * This is required for Firebase SDK compatibility. Firebase Swift pods (like FirebaseCoreInternal) - * depend on GoogleUtilities, which needs modular headers to be imported from Swift. - * - * Using `use_modular_headers!` instead of `use_frameworks! :linkage => :static` because: - * - `use_frameworks!` converts all pods to frameworks, which breaks React Native Firebase - * (RNFBApp can't import non-modular React Native headers like RCTConvert.h) - * - `use_modular_headers!` enables modular headers globally without converting pods to frameworks - * - * @see {@link https://github.com/invertase/react-native-firebase/issues/6332 RNFBApp modular headers issue} - * @see {@link https://docs.expo.dev/config-plugins/introduction/ Expo Config Plugins} - */ -const { withPodfile } = require('expo/config-plugins'); - -/** - * Injects `use_modular_headers!` into the iOS Podfile immediately after the `platform :ios` line if it's not already present. - * @param {import('expo/config-plugins').ExpoConfig} config - Expo config whose `modResults.contents` holds the Podfile; `modResults.contents` will be updated when insertion occurs. - * @returns {import('expo/config-plugins').ExpoConfig} The same config object, potentially with `modResults.contents` modified to include `use_modular_headers!`. - */ -function withModularHeaders(config) { - return withPodfile(config, (podfileConfig) => { - if (!podfileConfig.modResults || typeof podfileConfig.modResults.contents !== 'string') { - throw new Error('Invalid Podfile config: modResults.contents must be a string'); - } - - const podfile = podfileConfig.modResults.contents; - - // Check if use_modular_headers! is already present - if (podfile.includes('use_modular_headers!')) { - return podfileConfig; - } - - // Add use_modular_headers! after the platform declaration - // This ensures it's at the top level, before any targets - // Allow optional leading whitespace for indented Podfiles - const platformRegex = /^(\s*platform :ios.*$)/m; - const match = podfile.match(platformRegex); - - if (match) { - podfileConfig.modResults.contents = podfile.replace( - platformRegex, - `$1\n\n# Enable modular headers globally for Firebase Swift compatibility\nuse_modular_headers!` - ); - } - - return podfileConfig; - }); -} - -module.exports = withModularHeaders; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f28ec7da..0a17da63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@amplitude/analytics-browser': + specifier: ^2.33.0 + version: 2.33.0 + '@amplitude/analytics-react-native': + specifier: ^1.5.32 + version: 1.5.32(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@bottom-tabs/react-navigation': specifier: ^1.1.0 version: 1.1.0(@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-bottom-tabs@1.1.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -29,12 +35,6 @@ importers: '@react-native-community/datetimepicker': specifier: ^8.4.4 version: 8.4.4(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - '@react-native-firebase/analytics': - specifier: ^23.7.0 - version: 23.7.0(@react-native-firebase/app@23.7.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)))(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) - '@react-native-firebase/app': - specifier: ^23.7.0 - version: 23.7.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)))(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@react-navigation/bottom-tabs': specifier: ^7.4.0 version: 7.9.0(@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -125,9 +125,6 @@ importers: expo-web-browser: specifier: ~15.0.10 version: 15.0.10(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) - firebase: - specifier: ^12.7.0 - version: 12.7.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))) lucide-react-native: specifier: ^0.562.0 version: 0.562.0(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -183,6 +180,9 @@ importers: '@playwright/test': specifier: ^1.57.0 version: 1.57.0 + '@spotlightjs/spotlight': + specifier: ^4.9.0 + version: 4.9.0(hono-rate-limiter@0.4.2(hono@4.11.3)) '@testing-library/react-native': specifier: ^13.3.3 version: 13.3.3(jest@29.7.0(@types/node@25.0.3))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) @@ -245,6 +245,45 @@ packages: graphql: optional: true + '@amplitude/analytics-browser@2.33.0': + resolution: {integrity: sha512-7qrTIImI53o9Pnhun68kCY0Hhc3AaBG59wlr5A/lvD5fp6D85KZntz3whxmLqbKMGoudKqzhjdGKp1VNSNv28g==} + + '@amplitude/analytics-connector@1.6.4': + resolution: {integrity: sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==} + + '@amplitude/analytics-core@2.35.0': + resolution: {integrity: sha512-7RmHYELXCGu8yuO9D6lEXiqkMtiC5sePNhCWmwuP30dneDYHtH06gaYvAFH/YqOFuE6enwEEJfFYtcaPhyiqtA==} + + '@amplitude/analytics-react-native@1.5.32': + resolution: {integrity: sha512-/85s1IttVnMK934ZwGDBKt+jEdoaXdMUstBPVF/SvjyP9Gqvk+D19IbKyeeVz/ri7H7mntqQxwIC+KDT0rqj1Q==} + peerDependencies: + react: '*' + react-native: '*' + + '@amplitude/plugin-autocapture-browser@1.18.3': + resolution: {integrity: sha512-njYque5t1QCEEe5V8Ls4yVVklTM6V7OXxBk6pqznN/hj/Pc4X8Wjy898pZ2VtbnvpagBKKzGb5B6Syl8OXiicw==} + + '@amplitude/plugin-network-capture-browser@1.7.3': + resolution: {integrity: sha512-zfWgAN7g6AigJAsgrGmlgVwydOHH6XvweBoxhU+qEvRydboiIVCDLSxuXczUsBG7kYVLWRdBK1DYoE5J7lqTGA==} + + '@amplitude/plugin-page-url-enrichment-browser@0.5.9': + resolution: {integrity: sha512-TqdELx4WrdRutCjHUFUzum/f/UjhbdTZw0UKkYFAj5gwAKDjaPEjL4waRvINOTaVLsne1A6ck4KEMfC8AKByFw==} + + '@amplitude/plugin-page-view-tracking-browser@2.6.6': + resolution: {integrity: sha512-dBcJlrdKgPzSgS3exDRRrMLqhIaOjwlIy7o8sEMn1PpMawERlbumSSdtfII6L4L67HYUPo4PY4Kp4acqSzaLvQ==} + + '@amplitude/plugin-web-vitals-browser@1.1.3': + resolution: {integrity: sha512-dXtlmgjWBaQGQtWKy8wluF7JJJZ8cHJuUIajsQ6TvQtJvjV5E6B/kX9lDmGiyaVyQw9YmRf51DjfUfJhfAAyTg==} + + '@amplitude/ua-parser-js@0.7.33': + resolution: {integrity: sha512-wKEtVR4vXuPT9cVEIJkYWnlF++Gx3BdLatPBM+SZ1ztVIvnhdGBZR/mn9x/PzyrMcRlZmyi6L56I2J3doVBnjA==} + + '@apm-js-collab/code-transformer@0.8.2': + resolution: {integrity: sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA==} + + '@apm-js-collab/tracing-hooks@0.3.1': + resolution: {integrity: sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==} + '@babel/code-frame@7.10.4': resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==} @@ -932,251 +971,6 @@ packages: resolution: {integrity: sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw==} hasBin: true - '@firebase/ai@2.6.0': - resolution: {integrity: sha512-NGyE7NQDFznOv683Xk4+WoUv39iipa9lEfrwvvPz33ChzVbCCiB69FJQTK2BI/11pRtzYGbHo1/xMz7gxWWhJw==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app': 0.x - '@firebase/app-types': 0.x - - '@firebase/ai@2.6.1': - resolution: {integrity: sha512-qJd9bpABqsanFnwdbjZEDbKKr1jRtuUZ+cHyNBLWsxobH4pd73QncvuO3XlMq4eKBLlg1f5jNdFpJ3G3ABu2Tg==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app': 0.x - '@firebase/app-types': 0.x - - '@firebase/analytics-compat@0.2.25': - resolution: {integrity: sha512-fdzoaG0BEKbqksRDhmf4JoyZf16Wosrl0Y7tbZtJyVDOOwziE0vrFjmZuTdviL0yhak+Nco6rMsUUbkbD+qb6Q==} - peerDependencies: - '@firebase/app-compat': 0.x - - '@firebase/analytics-types@0.8.3': - resolution: {integrity: sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==} - - '@firebase/analytics@0.10.19': - resolution: {integrity: sha512-3wU676fh60gaiVYQEEXsbGS4HbF2XsiBphyvvqDbtC1U4/dO4coshbYktcCHq+HFaGIK07iHOh4pME0hEq1fcg==} - peerDependencies: - '@firebase/app': 0.x - - '@firebase/app-check-compat@0.4.0': - resolution: {integrity: sha512-UfK2Q8RJNjYM/8MFORltZRG9lJj11k0nW84rrffiKvcJxLf1jf6IEjCIkCamykHE73C6BwqhVfhIBs69GXQV0g==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app-compat': 0.x - - '@firebase/app-check-interop-types@0.3.3': - resolution: {integrity: sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==} - - '@firebase/app-check-types@0.5.3': - resolution: {integrity: sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==} - - '@firebase/app-check@0.11.0': - resolution: {integrity: sha512-XAvALQayUMBJo58U/rxW02IhsesaxxfWVmVkauZvGEz3vOAjMEQnzFlyblqkc2iAaO82uJ2ZVyZv9XzPfxjJ6w==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app': 0.x - - '@firebase/app-compat@0.5.6': - resolution: {integrity: sha512-YYGARbutghQY4zZUWMYia0ib0Y/rb52y72/N0z3vglRHL7ii/AaK9SA7S/dzScVOlCdnbHXz+sc5Dq+r8fwFAg==} - engines: {node: '>=20.0.0'} - - '@firebase/app-types@0.9.3': - resolution: {integrity: sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==} - - '@firebase/app@0.14.6': - resolution: {integrity: sha512-4uyt8BOrBsSq6i4yiOV/gG6BnnrvTeyymlNcaN/dKvyU1GoolxAafvIvaNP1RCGPlNab3OuE4MKUQuv2lH+PLQ==} - engines: {node: '>=20.0.0'} - - '@firebase/auth-compat@0.6.1': - resolution: {integrity: sha512-I0o2ZiZMnMTOQfqT22ur+zcGDVSAfdNZBHo26/Tfi8EllfR1BO7aTVo2rt/ts8o/FWsK8pOALLeVBGhZt8w/vg==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app-compat': 0.x - - '@firebase/auth-compat@0.6.2': - resolution: {integrity: sha512-8UhCzF6pav9bw/eXA8Zy1QAKssPRYEYXaWagie1ewLTwHkXv6bKp/j6/IwzSYQP67sy/BMFXIFaCCsoXzFLr7A==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app-compat': 0.x - - '@firebase/auth-interop-types@0.2.4': - resolution: {integrity: sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==} - - '@firebase/auth-types@0.13.0': - resolution: {integrity: sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==} - peerDependencies: - '@firebase/app-types': 0.x - '@firebase/util': 1.x - - '@firebase/auth@1.11.1': - resolution: {integrity: sha512-Mea0G/BwC1D0voSG+60Ylu3KZchXAFilXQ/hJXWCw3gebAu+RDINZA0dJMNeym7HFxBaBaByX8jSa7ys5+F2VA==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app': 0.x - '@react-native-async-storage/async-storage': ^1.18.1 - peerDependenciesMeta: - '@react-native-async-storage/async-storage': - optional: true - - '@firebase/auth@1.12.0': - resolution: {integrity: sha512-zkvLpsrxynWHk07qGrUDfCSqKf4AvfZGEqJ7mVCtYGjNNDbGE71k0Yn84rg8QEZu4hQw1BC0qDEHzpNVBcSVmA==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app': 0.x - '@react-native-async-storage/async-storage': ^2.2.0 - peerDependenciesMeta: - '@react-native-async-storage/async-storage': - optional: true - - '@firebase/component@0.7.0': - resolution: {integrity: sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==} - engines: {node: '>=20.0.0'} - - '@firebase/data-connect@0.3.12': - resolution: {integrity: sha512-baPddcoNLj/+vYo+HSJidJUdr5W4OkhT109c5qhR8T1dJoZcyJpkv/dFpYlw/VJ3dV66vI8GHQFrmAZw/xUS4g==} - peerDependencies: - '@firebase/app': 0.x - - '@firebase/database-compat@2.1.0': - resolution: {integrity: sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==} - engines: {node: '>=20.0.0'} - - '@firebase/database-types@1.0.16': - resolution: {integrity: sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==} - - '@firebase/database@1.1.0': - resolution: {integrity: sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==} - engines: {node: '>=20.0.0'} - - '@firebase/firestore-compat@0.4.2': - resolution: {integrity: sha512-cy7ov6SpFBx+PHwFdOOjbI7kH00uNKmIFurAn560WiPCZXy9EMnil1SOG7VF4hHZKdenC+AHtL4r3fNpirpm0w==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app-compat': 0.x - - '@firebase/firestore-compat@0.4.3': - resolution: {integrity: sha512-1ylF/njF68Pmb6p0erP0U78XQv1w77Wap4bUmqZ7ZVkmN1oMgplyu0TyirWtCBoKFRV2+SUZfWXvIij/z39LYg==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app-compat': 0.x - - '@firebase/firestore-types@3.0.3': - resolution: {integrity: sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==} - peerDependencies: - '@firebase/app-types': 0.x - '@firebase/util': 1.x - - '@firebase/firestore@4.9.2': - resolution: {integrity: sha512-iuA5+nVr/IV/Thm0Luoqf2mERUvK9g791FZpUJV1ZGXO6RL2/i/WFJUj5ZTVXy5pRjpWYO+ZzPcReNrlilmztA==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app': 0.x - - '@firebase/firestore@4.9.3': - resolution: {integrity: sha512-RVuvhcQzs1sD5Osr2naQS71H0bQMbSnib16uOWAKk3GaKb/WBPyCYSr2Ry7MqlxDP/YhwknUxECL07lw9Rq1nA==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app': 0.x - - '@firebase/functions-compat@0.4.1': - resolution: {integrity: sha512-AxxUBXKuPrWaVNQ8o1cG1GaCAtXT8a0eaTDfqgS5VsRYLAR0ALcfqDLwo/QyijZj1w8Qf8n3Qrfy/+Im245hOQ==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app-compat': 0.x - - '@firebase/functions-types@0.6.3': - resolution: {integrity: sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==} - - '@firebase/functions@0.13.1': - resolution: {integrity: sha512-sUeWSb0rw5T+6wuV2o9XNmh9yHxjFI9zVGFnjFi+n7drTEWpl7ZTz1nROgGrSu472r+LAaj+2YaSicD4R8wfbw==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app': 0.x - - '@firebase/installations-compat@0.2.19': - resolution: {integrity: sha512-khfzIY3EI5LePePo7vT19/VEIH1E3iYsHknI/6ek9T8QCozAZshWT9CjlwOzZrKvTHMeNcbpo/VSOSIWDSjWdQ==} - peerDependencies: - '@firebase/app-compat': 0.x - - '@firebase/installations-types@0.5.3': - resolution: {integrity: sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==} - peerDependencies: - '@firebase/app-types': 0.x - - '@firebase/installations@0.6.19': - resolution: {integrity: sha512-nGDmiwKLI1lerhwfwSHvMR9RZuIH5/8E3kgUWnVRqqL7kGVSktjLTWEMva7oh5yxQ3zXfIlIwJwMcaM5bK5j8Q==} - peerDependencies: - '@firebase/app': 0.x - - '@firebase/logger@0.5.0': - resolution: {integrity: sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==} - engines: {node: '>=20.0.0'} - - '@firebase/messaging-compat@0.2.23': - resolution: {integrity: sha512-SN857v/kBUvlQ9X/UjAqBoQ2FEaL1ZozpnmL1ByTe57iXkmnVVFm9KqAsTfmf+OEwWI4kJJe9NObtN/w22lUgg==} - peerDependencies: - '@firebase/app-compat': 0.x - - '@firebase/messaging-interop-types@0.2.3': - resolution: {integrity: sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==} - - '@firebase/messaging@0.12.23': - resolution: {integrity: sha512-cfuzv47XxqW4HH/OcR5rM+AlQd1xL/VhuaeW/wzMW1LFrsFcTn0GND/hak1vkQc2th8UisBcrkVcQAnOnKwYxg==} - peerDependencies: - '@firebase/app': 0.x - - '@firebase/performance-compat@0.2.22': - resolution: {integrity: sha512-xLKxaSAl/FVi10wDX/CHIYEUP13jXUjinL+UaNXT9ByIvxII5Ne5150mx6IgM8G6Q3V+sPiw9C8/kygkyHUVxg==} - peerDependencies: - '@firebase/app-compat': 0.x - - '@firebase/performance-types@0.2.3': - resolution: {integrity: sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==} - - '@firebase/performance@0.7.9': - resolution: {integrity: sha512-UzybENl1EdM2I1sjYm74xGt/0JzRnU/0VmfMAKo2LSpHJzaj77FCLZXmYQ4oOuE+Pxtt8Wy2BVJEENiZkaZAzQ==} - peerDependencies: - '@firebase/app': 0.x - - '@firebase/remote-config-compat@0.2.20': - resolution: {integrity: sha512-P/ULS9vU35EL9maG7xp66uljkZgcPMQOxLj3Zx2F289baTKSInE6+YIkgHEi1TwHoddC/AFePXPpshPlEFkbgg==} - peerDependencies: - '@firebase/app-compat': 0.x - - '@firebase/remote-config-types@0.5.0': - resolution: {integrity: sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==} - - '@firebase/remote-config@0.7.0': - resolution: {integrity: sha512-dX95X6WlW7QlgNd7aaGdjAIZUiQkgWgNS+aKNu4Wv92H1T8Ue/NDUjZHd9xb8fHxLXIHNZeco9/qbZzr500MjQ==} - peerDependencies: - '@firebase/app': 0.x - - '@firebase/storage-compat@0.4.0': - resolution: {integrity: sha512-vDzhgGczr1OfcOy285YAPur5pWDEvD67w4thyeCUh6Ys0izN9fNYtA1MJERmNBfqjqu0lg0FM5GLbw0Il21M+g==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app-compat': 0.x - - '@firebase/storage-types@0.8.3': - resolution: {integrity: sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==} - peerDependencies: - '@firebase/app-types': 0.x - '@firebase/util': 1.x - - '@firebase/storage@0.14.0': - resolution: {integrity: sha512-xWWbb15o6/pWEw8H01UQ1dC5U3rf8QTAzOChYyCpafV6Xki7KVp3Yaw2nSklUwHEziSWE9KoZJS7iYeyqWnYFA==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@firebase/app': 0.x - - '@firebase/util@1.13.0': - resolution: {integrity: sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==} - engines: {node: '>=20.0.0'} - - '@firebase/webchannel-wrapper@1.0.5': - resolution: {integrity: sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==} - '@gorhom/bottom-sheet@5.2.8': resolution: {integrity: sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA==} peerDependencies: @@ -1198,14 +992,19 @@ packages: react: '*' react-native: '*' - '@grpc/grpc-js@1.9.15': - resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==} - engines: {node: ^8.13.0 || >=10.10.0} + '@hono/mcp@0.2.3': + resolution: {integrity: sha512-wrYKVQSjnBg4/ZinnnP/1t3jlvP3Z9fqZv8OzuhLXV4gVTLOHOHDhnXsIwD0nFVKk2pMOvA+sDfrKyRzw4yUPg==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.1 + hono: '*' + hono-rate-limiter: ^0.4.2 + zod: ^3.25.0 || ^4.0.0 - '@grpc/proto-loader@0.7.15': - resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} - engines: {node: '>=6'} - hasBin: true + '@hono/node-server@1.19.7': + resolution: {integrity: sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -1348,6 +1147,16 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@modelcontextprotocol/sdk@1.25.1': + resolution: {integrity: sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1355,40 +1164,199 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@playwright/test@1.57.0': - resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} - engines: {node: '>=18'} - hasBin: true + '@opentelemetry/api-logs@0.208.0': + resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/context-async-hooks@2.2.0': + resolution: {integrity: sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.2.0': + resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/instrumentation-amqplib@0.55.0': + resolution: {integrity: sha512-5ULoU8p+tWcQw5PDYZn8rySptGSLZHNX/7srqo2TioPnAAcvTy6sQFQXsNPrAnyRRtYGMetXVyZUy5OaX1+IfA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.52.0': + resolution: {integrity: sha512-GXPxfNB5szMbV3I9b7kNWSmQBoBzw7MT0ui6iU/p+NIzVx3a06Ri2cdQO7tG9EKb4aKSLmfX9Cw5cKxXqX6Ohg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dataloader@0.26.0': + resolution: {integrity: sha512-P2BgnFfTOarZ5OKPmYfbXfDFjQ4P9WkQ1Jji7yH5/WwB6Wm/knynAoA1rxbjWcDlYupFkyT0M1j6XLzDzy0aCA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.57.0': + resolution: {integrity: sha512-HAdx/o58+8tSR5iW+ru4PHnEejyKrAy9fYFhlEI81o10nYxrGahnMAHWiSjhDC7UQSY3I4gjcPgSKQz4rm/asg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.28.0': + resolution: {integrity: sha512-FFvg8fq53RRXVBRHZViP+EMxMR03tqzEGpuq55lHNbVPyFklSVfQBN50syPhK5UYYwaStx0eyCtHtbRreusc5g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.52.0': + resolution: {integrity: sha512-ISkNcv5CM2IwvsMVL31Tl61/p2Zm2I2NAsYq5SSBgOsOndT0TjnptjufYVScCnD5ZLD1tpl4T3GEYULLYOdIdQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.56.0': + resolution: {integrity: sha512-IPvNk8AFoVzTAM0Z399t34VDmGDgwT6rIqCUug8P9oAGerl2/PEIYMPOl/rerPGu+q8gSWdmbFSjgg7PDVRd3Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.55.0': + resolution: {integrity: sha512-prqAkRf9e4eEpy4G3UcR32prKE8NLNlA90TdEU1UsghOTg0jUvs40Jz8LQWFEs5NbLbXHYGzB4CYVkCI8eWEVQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 - '@protobufjs/aspromise@1.1.2': - resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + '@opentelemetry/instrumentation-http@0.208.0': + resolution: {integrity: sha512-rhmK46DRWEbQQB77RxmVXGyjs6783crXCnFjYQj+4tDH/Kpv9Rbg3h2kaNyp5Vz2emF1f9HOQQvZoHzwMWOFZQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.56.0': + resolution: {integrity: sha512-XSWeqsd3rKSsT3WBz/JKJDcZD4QYElZEa0xVdX8f9dh4h4QgXhKRLorVsVkK3uXFbC2sZKAS2Ds+YolGwD83Dg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.18.0': + resolution: {integrity: sha512-KCL/1HnZN5zkUMgPyOxfGjLjbXjpd4odDToy+7c+UsthIzVLFf99LnfIBE8YSSrYE4+uS7OwJMhvhg3tWjqMBg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.53.0': + resolution: {integrity: sha512-xngn5cH2mVXFmiT1XfQ1aHqq1m4xb5wvU6j9lSgLlihJ1bXzsO543cpDwjrZm2nMrlpddBf55w8+bfS4qDh60g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.57.0': + resolution: {integrity: sha512-3JS8PU/D5E3q295mwloU2v7c7/m+DyCqdu62BIzWt+3u9utjxC9QS7v6WmUNuoDN3RM+Q+D1Gpj13ERo+m7CGg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.53.0': + resolution: {integrity: sha512-LDwWz5cPkWWr0HBIuZUjslyvijljTwmwiItpMTHujaULZCxcYE9eU44Qf/pbVC8TulT0IhZi+RoGvHKXvNhysw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.61.0': + resolution: {integrity: sha512-OV3i2DSoY5M/pmLk+68xr5RvkHU8DRB3DKMzYJdwDdcxeLs62tLbkmRyqJZsYf3Ht7j11rq35pHOWLuLzXL7pQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.55.0': + resolution: {integrity: sha512-5afj0HfF6aM6Nlqgu6/PPHFk8QBfIe3+zF9FGpX76jWPS0/dujoEYn82/XcLSaW5LPUDW8sni+YeK0vTBNri+w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.55.0': + resolution: {integrity: sha512-0cs8whQG55aIi20gnK8B7cco6OK6N+enNhW0p5284MvqJ5EPi+I1YlWsWXgzv/V2HFirEejkvKiI4Iw21OqDWg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.54.0': + resolution: {integrity: sha512-bqC1YhnwAeWmRzy1/Xf9cDqxNG2d/JDkaxnqF5N6iJKN1eVWI+vg7NfDkf52/Nggp3tl1jcC++ptC61BD6738A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.61.0': + resolution: {integrity: sha512-UeV7KeTnRSM7ECHa3YscoklhUtTQPs6V6qYpG283AB7xpnPGCUCUfECFT9jFg6/iZOQTt3FHkB1wGTJCNZEvPw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.57.0': + resolution: {integrity: sha512-bCxTHQFXzrU3eU1LZnOZQ3s5LURxQPDlU3/upBzlWY77qOI1GZuGofazj3jtzjctMJeBEJhNwIFEgRPBX1kp/Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.27.0': + resolution: {integrity: sha512-jRtyUJNZppPBjPae4ZjIQ2eqJbcRaRfJkr0lQLHFmOU/no5A6e9s1OHLd5XZyZoBJ/ymngZitanyRRA5cniseA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 - '@protobufjs/base64@1.1.2': - resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + '@opentelemetry/instrumentation-undici@0.19.0': + resolution: {integrity: sha512-Pst/RhR61A2OoZQZkn6OLpdVpXp6qn3Y92wXa6umfJe9rV640r4bc6SWvw4pPN6DiQqPu2c8gnSSZPDtC6JlpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 - '@protobufjs/codegen@2.0.4': - resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + '@opentelemetry/instrumentation@0.208.0': + resolution: {integrity: sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 - '@protobufjs/eventemitter@1.1.0': - resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + '@opentelemetry/redis-common@0.38.2': + resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} + engines: {node: ^18.19.0 || >=20.6.0} - '@protobufjs/fetch@1.1.0': - resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + '@opentelemetry/resources@2.2.0': + resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@protobufjs/float@1.0.2': - resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + '@opentelemetry/sdk-trace-base@2.2.0': + resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@protobufjs/inquire@1.1.0': - resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + '@opentelemetry/semantic-conventions@1.38.0': + resolution: {integrity: sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==} + engines: {node: '>=14'} - '@protobufjs/path@1.1.2': - resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + '@opentelemetry/sql-common@0.41.2': + resolution: {integrity: sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.1.0 - '@protobufjs/pool@1.1.0': - resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + '@playwright/test@1.57.0': + resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} + engines: {node: '>=18'} + hasBin: true - '@protobufjs/utf8@1.1.0': - resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@prisma/instrumentation@6.19.0': + resolution: {integrity: sha512-QcuYy25pkXM8BJ37wVFBO7Zh34nyRV1GOb2n3lPkkbRYfl4hWl3PTcImP41P0KrzVXfa/45p6eVCos27x3exIg==} + peerDependencies: + '@opentelemetry/api': ^1.8 '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -1618,6 +1586,11 @@ packages: '@types/react': optional: true + '@react-native-async-storage/async-storage@1.24.0': + resolution: {integrity: sha512-W4/vbwUOYOjco0x3toB8QCr7EjIP6nE9G7o8PMguvvjYT5Awg09lyV4enACRx4s++PPulBiBSjL0KTFx2u0Z/g==} + peerDependencies: + react-native: ^0.0.0-0 || >=0.60 <1.0 + '@react-native-async-storage/async-storage@2.2.0': resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==} peerDependencies: @@ -1636,21 +1609,6 @@ packages: react-native-windows: optional: true - '@react-native-firebase/analytics@23.7.0': - resolution: {integrity: sha512-5oEWNN7WLj2WHjG4wnwSda4GMM0gVfsKGbbUEX3vl4B4hxy/OEHDNWWVGj93qShHWlj8NApsIc6TzrVLhJbCGQ==} - peerDependencies: - '@react-native-firebase/app': 23.7.0 - - '@react-native-firebase/app@23.7.0': - resolution: {integrity: sha512-sYVDkDxlOyQaDO/A0yVqbTha32dVapHlzS054RPY+RM5m0vARMsevJ9d543kH+Cdbp1RKMHIgDjhlB+APaNdhw==} - peerDependencies: - expo: '>=47.0.0' - react: '*' - react-native: '*' - peerDependenciesMeta: - expo: - optional: true - '@react-native/assets-registry@0.81.5': resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==} engines: {node: '>= 20.19.4'} @@ -1895,6 +1853,36 @@ packages: resolution: {integrity: sha512-Jrf0Yo7DvmI/ZQcvBnA0xKNAFkJlVC/fMlvcin+5IrFNRcqOToZ2vtF+XqTgjRZymXQNE8s1QTD7IomPHk0TAw==} engines: {node: '>=18'} + '@sentry/core@10.32.1': + resolution: {integrity: sha512-PH2ldpSJlhqsMj2vCTyU0BI2Fx1oIDhm7Izo5xFALvjVCS0gmlqHt1udu6YlKn8BtpGH6bGzssvv5APrk+OdPQ==} + engines: {node: '>=18'} + + '@sentry/node-core@10.32.1': + resolution: {integrity: sha512-w56rxdBanBKc832zuwnE+zNzUQ19fPxfHEtOhK8JGPu3aSwQYcIxwz9z52lOx3HN7k/8Fj5694qlT3x/PokhRw==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/context-async-hooks': ^1.30.1 || ^2.1.0 || ^2.2.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 || ^2.2.0 + '@opentelemetry/instrumentation': '>=0.57.1 <1' + '@opentelemetry/resources': ^1.30.1 || ^2.1.0 || ^2.2.0 + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 || ^2.2.0 + '@opentelemetry/semantic-conventions': ^1.37.0 + + '@sentry/node@10.32.1': + resolution: {integrity: sha512-oxlybzt8QW0lx/QaEj1DcvZDRXkgouewFelu/10dyUwv5So3YvipfvWInda+yMLmn25OggbloDQ0gyScA2jU3g==} + engines: {node: '>=18'} + + '@sentry/opentelemetry@10.32.1': + resolution: {integrity: sha512-YLssSz5Y+qPvufrh2cDaTXDoXU8aceOhB+YTjT8/DLF6SOj7Tzen52aAcjNaifawaxEsLCC8O+B+A2iA+BllvA==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/context-async-hooks': ^1.30.1 || ^2.1.0 || ^2.2.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 || ^2.2.0 + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 || ^2.2.0 + '@opentelemetry/semantic-conventions': ^1.37.0 + '@sentry/react-native@7.2.0': resolution: {integrity: sha512-rjqYgEjntPz1sPysud78wi4B9ui7LBVPsG6qr8s/htLMYho9GPGFA5dF+eqsQWqMX8NDReAxNkLTC4+gCNklLQ==} hasBin: true @@ -1928,6 +1916,11 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@spotlightjs/spotlight@4.9.0': + resolution: {integrity: sha512-tX5z1d9+yGcBkqF7609gJ9dTWQ2TDz/zcyeFV1ElWCpPUvjQdafwaFNW7q30n1Bord8zEyYHsRdFgk3qD43KWQ==} + engines: {node: '>=20'} + hasBin: true + '@supabase/auth-js@2.89.0': resolution: {integrity: sha512-wiWZdz8WMad8LQdJMWYDZ2SJtZP5MwMqzQq3ehtW2ngiI3UTgbKiFrvMUUS3KADiVlk4LiGfODB2mrYx7w2f8w==} engines: {node: '>=20.0.0'} @@ -1983,6 +1976,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -2013,9 +2009,18 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/mysql@2.15.27': + resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} + '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/pg-pool@2.0.6': + resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/phoenix@1.6.7': resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} @@ -2028,6 +2033,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -2040,6 +2048,9 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@types/zen-observable@0.8.3': + resolution: {integrity: sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==} + '@typescript-eslint/eslint-plugin@8.50.1': resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2247,9 +2258,18 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-globals@7.0.1: resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2272,6 +2292,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -2281,6 +2309,9 @@ packages: anser@1.4.10: resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + anser@2.3.5: + resolution: {integrity: sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -2468,6 +2499,10 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + body-parser@2.2.1: + resolution: {integrity: sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -2545,6 +2580,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + char-regex@1.0.2: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} @@ -2682,12 +2721,32 @@ packages: resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} engines: {node: '>= 0.10.0'} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-js-compat@3.47.0: resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==} + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + create-jest@29.7.0: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3117,6 +3176,18 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + eventsource@4.1.0: + resolution: {integrity: sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==} + engines: {node: '>=20.0.0'} + exec-async@2.2.0: resolution: {integrity: sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==} @@ -3381,9 +3452,22 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@7.5.1: + resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fuzzy@1.12.0: + resolution: {integrity: sha512-sXxGgHS+ubYpsdLnvOvJ9w5GYYZrtL9mkosG3nfuD446ahvoWEsSKBP7ieGmWIKVLnaxRDgUJkZMdxRgA2Ni+Q==} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -3393,10 +3477,6 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - faye-websocket@0.11.4: - resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} - engines: {node: '>=0.8.0'} - fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} @@ -3431,6 +3511,10 @@ packages: resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -3439,12 +3523,6 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - firebase@12.6.0: - resolution: {integrity: sha512-8ZD1Gcv916Qp8/nsFH2+QMIrfX/76ti6cJwxQUENLXXnKlOX/IJZaU2Y3bdYf5r1mbownrQKfnWtrt+MVgdwLA==} - - firebase@12.7.0: - resolution: {integrity: sha512-ZBZg9jFo8uH4Emd7caOqtalKJfDGHnHQSrCPiqRAdTFQd0wL3ERilUBfhnhBLnlernugkN/o7nJa0p+sE71Izg==} - flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -3466,6 +3544,13 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + freeport-async@2.0.0: resolution: {integrity: sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==} engines: {node: '>=8'} @@ -3474,6 +3559,10 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -3579,6 +3668,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemesplit@2.6.0: + resolution: {integrity: sha512-rG9w2wAfkpg0DILa1pjnjNfucng3usON360shisqIMUBw/87pojcBSrHmeE4UwryAuBih7g8m1oilf5/u8EWdQ==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -3625,6 +3717,15 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + hono-rate-limiter@0.4.2: + resolution: {integrity: sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw==} + peerDependencies: + hono: ^4.1.1 + + hono@4.11.3: + resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==} + engines: {node: '>=16.9.0'} + hosted-git-info@7.0.2: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} @@ -3640,9 +3741,6 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - http-parser-js@0.5.10: - resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} - http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} @@ -3675,8 +3773,9 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} - idb@7.1.1: - resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + iconv-lite@0.7.1: + resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} + engines: {node: '>=0.10.0'} ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3698,6 +3797,9 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-in-the-middle@2.0.1: + resolution: {integrity: sha512-bruMpJ7xz+9jwGzrwEhWgvRrlKRYCRDBrfU+ur3FcasYXLJDxTruJ//8g2Noj+QFyRBeqbpj8Bhn4Fbw6HjvhA==} + import-local@3.2.0: resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} engines: {node: '>=8'} @@ -3731,6 +3833,10 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -3828,6 +3934,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -4055,6 +4164,12 @@ packages: jimp-compact@0.16.1: resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4095,6 +4210,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -4122,6 +4240,9 @@ packages: resolution: {integrity: sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==} hasBin: true + launch-editor@2.12.0: + resolution: {integrity: sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -4223,9 +4344,6 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash.camelcase@4.3.0: - resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} - lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -4243,8 +4361,9 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} - long@5.3.2: - resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + logfmt@1.4.0: + resolution: {integrity: sha512-p1Ow0C2dDJYaQBhRHt+HVMP6ELuBm4jYSYNHPMfz0J5wJ9qA6/7oBOlBZBfT1InqguTYcvJzNea5FItDxTcbyw==} + hasBin: true loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} @@ -4281,15 +4400,27 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mcp-proxy@5.12.5: + resolution: {integrity: sha512-Vawdc8vi36fXxKCaDpluRvbGcmrUXJdvXcDhkh30HYsws8XqX2rWPBflZpavzeS+6SwijRFV7g+9ypQRJZlrEQ==} + hasBin: true + mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-options@3.0.4: resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} engines: {node: '>=10'} @@ -4371,6 +4502,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -4419,6 +4554,9 @@ packages: engines: {node: '>=10'} hasBin: true + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -4453,6 +4591,10 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + nested-error-stacks@2.0.1: resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==} @@ -4599,6 +4741,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4637,6 +4782,20 @@ packages: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4661,6 +4820,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -4694,6 +4857,22 @@ packages: resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -4736,9 +4915,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - protobufjs@7.5.4: - resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} - engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -4757,6 +4936,10 @@ packages: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + engines: {node: '>=0.6'} + query-string@7.1.3: resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} engines: {node: '>=6'} @@ -4771,6 +4954,10 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -4977,6 +5164,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + requireg@0.2.2: resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==} engines: {node: '>= 4.0.0'} @@ -5038,6 +5229,13 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -5089,6 +5287,10 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-error@2.1.0: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} @@ -5097,6 +5299,10 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} @@ -5203,6 +5409,9 @@ packages: resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} engines: {node: '>=6'} + split@0.2.10: + resolution: {integrity: sha512-e0pKq+UUH2Xq/sXbYpZBZc3BawsfDZ7dgv+JtRTUPNcvF5CMR4Y9cvJqkMY0MoxWzTHvZuz1beg6pNEKlszPiQ==} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -5326,10 +5535,6 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true - superstruct@2.0.2: - resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} - engines: {node: '>=14.0.0'} - supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -5384,6 +5589,12 @@ packages: throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -5441,6 +5652,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -5497,6 +5712,9 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unique-string@2.0.0: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} @@ -5562,6 +5780,10 @@ packages: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} hasBin: true + uuidv7@1.1.0: + resolution: {integrity: sha512-2VNnOC0+XQlwogChUDzy6pe8GQEys9QFZBGOh54l6qVfwoCUwwRvk7rDTgaIsRgsF5GFa5oiNg8LqXE3jofBBg==} + hasBin: true + v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} @@ -5596,8 +5818,8 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - web-vitals@4.2.4: - resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + web-vitals@5.0.1: + resolution: {integrity: sha512-BsULPWaCKAAtNntUz0aJq1cu1wyuWmDzf4N6vYNMbYA6zzQAf2pzCYbyClf+Ui2MI54bt225AwugXIfL1W+Syg==} webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -5610,14 +5832,6 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} - websocket-driver@0.7.4: - resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} - engines: {node: '>=0.8.0'} - - websocket-extensions@0.1.4: - resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} - engines: {node: '>=0.8.0'} - whatwg-encoding@2.0.0: resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} engines: {node: '>=12'} @@ -5741,6 +5955,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -5769,10 +5987,90 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zen-observable-ts@1.1.0: + resolution: {integrity: sha512-1h4zlLSqI2cRLPJUHJFL8bCWHhkpuXkF+dbGkRaWjgDIG26DmzyshUMrdV/rL3UnR+mhaX4fRq8LPouq0MYYIA==} + + zen-observable@0.8.15: + resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod@4.2.1: + resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + snapshots: '@0no-co/graphql.web@1.2.0': {} + '@amplitude/analytics-browser@2.33.0': + dependencies: + '@amplitude/analytics-core': 2.35.0 + '@amplitude/plugin-autocapture-browser': 1.18.3 + '@amplitude/plugin-network-capture-browser': 1.7.3 + '@amplitude/plugin-page-url-enrichment-browser': 0.5.9 + '@amplitude/plugin-page-view-tracking-browser': 2.6.6 + '@amplitude/plugin-web-vitals-browser': 1.1.3 + tslib: 2.8.1 + + '@amplitude/analytics-connector@1.6.4': {} + + '@amplitude/analytics-core@2.35.0': + dependencies: + '@amplitude/analytics-connector': 1.6.4 + tslib: 2.8.1 + zen-observable-ts: 1.1.0 + + '@amplitude/analytics-react-native@1.5.32(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + '@amplitude/analytics-core': 2.35.0 + '@amplitude/ua-parser-js': 0.7.33 + '@react-native-async-storage/async-storage': 1.24.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + tslib: 2.8.1 + + '@amplitude/plugin-autocapture-browser@1.18.3': + dependencies: + '@amplitude/analytics-core': 2.35.0 + rxjs: 7.8.2 + tslib: 2.8.1 + + '@amplitude/plugin-network-capture-browser@1.7.3': + dependencies: + '@amplitude/analytics-core': 2.35.0 + tslib: 2.8.1 + + '@amplitude/plugin-page-url-enrichment-browser@0.5.9': + dependencies: + '@amplitude/analytics-core': 2.35.0 + tslib: 2.8.1 + + '@amplitude/plugin-page-view-tracking-browser@2.6.6': + dependencies: + '@amplitude/analytics-core': 2.35.0 + tslib: 2.8.1 + + '@amplitude/plugin-web-vitals-browser@1.1.3': + dependencies: + '@amplitude/analytics-core': 2.35.0 + tslib: 2.8.1 + web-vitals: 5.0.1 + + '@amplitude/ua-parser-js@0.7.33': {} + + '@apm-js-collab/code-transformer@0.8.2': {} + + '@apm-js-collab/tracing-hooks@0.3.1': + dependencies: + '@apm-js-collab/code-transformer': 0.8.2 + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + '@babel/code-frame@7.10.4': dependencies: '@babel/highlight': 7.25.9 @@ -6764,382 +7062,6 @@ snapshots: find-up: 5.0.0 js-yaml: 4.1.1 - '@firebase/ai@2.6.0(@firebase/app-types@0.9.3)(@firebase/app@0.14.6)': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/app-check-interop-types': 0.3.3 - '@firebase/app-types': 0.9.3 - '@firebase/component': 0.7.0 - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - - '@firebase/ai@2.6.1(@firebase/app-types@0.9.3)(@firebase/app@0.14.6)': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/app-check-interop-types': 0.3.3 - '@firebase/app-types': 0.9.3 - '@firebase/component': 0.7.0 - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - - '@firebase/analytics-compat@0.2.25(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6)': - dependencies: - '@firebase/analytics': 0.10.19(@firebase/app@0.14.6) - '@firebase/analytics-types': 0.8.3 - '@firebase/app-compat': 0.5.6 - '@firebase/component': 0.7.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@firebase/app' - - '@firebase/analytics-types@0.8.3': {} - - '@firebase/analytics@0.10.19(@firebase/app@0.14.6)': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/component': 0.7.0 - '@firebase/installations': 0.6.19(@firebase/app@0.14.6) - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - - '@firebase/app-check-compat@0.4.0(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6)': - dependencies: - '@firebase/app-check': 0.11.0(@firebase/app@0.14.6) - '@firebase/app-check-types': 0.5.3 - '@firebase/app-compat': 0.5.6 - '@firebase/component': 0.7.0 - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@firebase/app' - - '@firebase/app-check-interop-types@0.3.3': {} - - '@firebase/app-check-types@0.5.3': {} - - '@firebase/app-check@0.11.0(@firebase/app@0.14.6)': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/component': 0.7.0 - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - - '@firebase/app-compat@0.5.6': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/component': 0.7.0 - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - - '@firebase/app-types@0.9.3': {} - - '@firebase/app@0.14.6': - dependencies: - '@firebase/component': 0.7.0 - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - idb: 7.1.1 - tslib: 2.8.1 - - '@firebase/auth-compat@0.6.1(@firebase/app-compat@0.5.6)(@firebase/app-types@0.9.3)(@firebase/app@0.14.6)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)))': - dependencies: - '@firebase/app-compat': 0.5.6 - '@firebase/auth': 1.11.1(@firebase/app@0.14.6)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))) - '@firebase/auth-types': 0.13.0(@firebase/app-types@0.9.3)(@firebase/util@1.13.0) - '@firebase/component': 0.7.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@firebase/app' - - '@firebase/app-types' - - '@react-native-async-storage/async-storage' - - '@firebase/auth-compat@0.6.2(@firebase/app-compat@0.5.6)(@firebase/app-types@0.9.3)(@firebase/app@0.14.6)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)))': - dependencies: - '@firebase/app-compat': 0.5.6 - '@firebase/auth': 1.12.0(@firebase/app@0.14.6)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))) - '@firebase/auth-types': 0.13.0(@firebase/app-types@0.9.3)(@firebase/util@1.13.0) - '@firebase/component': 0.7.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@firebase/app' - - '@firebase/app-types' - - '@react-native-async-storage/async-storage' - - '@firebase/auth-interop-types@0.2.4': {} - - '@firebase/auth-types@0.13.0(@firebase/app-types@0.9.3)(@firebase/util@1.13.0)': - dependencies: - '@firebase/app-types': 0.9.3 - '@firebase/util': 1.13.0 - - '@firebase/auth@1.11.1(@firebase/app@0.14.6)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)))': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/component': 0.7.0 - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - optionalDependencies: - '@react-native-async-storage/async-storage': 2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) - - '@firebase/auth@1.12.0(@firebase/app@0.14.6)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)))': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/component': 0.7.0 - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - optionalDependencies: - '@react-native-async-storage/async-storage': 2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) - - '@firebase/component@0.7.0': - dependencies: - '@firebase/util': 1.13.0 - tslib: 2.8.1 - - '@firebase/data-connect@0.3.12(@firebase/app@0.14.6)': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/auth-interop-types': 0.2.4 - '@firebase/component': 0.7.0 - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - - '@firebase/database-compat@2.1.0': - dependencies: - '@firebase/component': 0.7.0 - '@firebase/database': 1.1.0 - '@firebase/database-types': 1.0.16 - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - - '@firebase/database-types@1.0.16': - dependencies: - '@firebase/app-types': 0.9.3 - '@firebase/util': 1.13.0 - - '@firebase/database@1.1.0': - dependencies: - '@firebase/app-check-interop-types': 0.3.3 - '@firebase/auth-interop-types': 0.2.4 - '@firebase/component': 0.7.0 - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - faye-websocket: 0.11.4 - tslib: 2.8.1 - - '@firebase/firestore-compat@0.4.2(@firebase/app-compat@0.5.6)(@firebase/app-types@0.9.3)(@firebase/app@0.14.6)': - dependencies: - '@firebase/app-compat': 0.5.6 - '@firebase/component': 0.7.0 - '@firebase/firestore': 4.9.2(@firebase/app@0.14.6) - '@firebase/firestore-types': 3.0.3(@firebase/app-types@0.9.3)(@firebase/util@1.13.0) - '@firebase/util': 1.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@firebase/app' - - '@firebase/app-types' - - '@firebase/firestore-compat@0.4.3(@firebase/app-compat@0.5.6)(@firebase/app-types@0.9.3)(@firebase/app@0.14.6)': - dependencies: - '@firebase/app-compat': 0.5.6 - '@firebase/component': 0.7.0 - '@firebase/firestore': 4.9.3(@firebase/app@0.14.6) - '@firebase/firestore-types': 3.0.3(@firebase/app-types@0.9.3)(@firebase/util@1.13.0) - '@firebase/util': 1.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@firebase/app' - - '@firebase/app-types' - - '@firebase/firestore-types@3.0.3(@firebase/app-types@0.9.3)(@firebase/util@1.13.0)': - dependencies: - '@firebase/app-types': 0.9.3 - '@firebase/util': 1.13.0 - - '@firebase/firestore@4.9.2(@firebase/app@0.14.6)': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/component': 0.7.0 - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - '@firebase/webchannel-wrapper': 1.0.5 - '@grpc/grpc-js': 1.9.15 - '@grpc/proto-loader': 0.7.15 - tslib: 2.8.1 - - '@firebase/firestore@4.9.3(@firebase/app@0.14.6)': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/component': 0.7.0 - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - '@firebase/webchannel-wrapper': 1.0.5 - '@grpc/grpc-js': 1.9.15 - '@grpc/proto-loader': 0.7.15 - tslib: 2.8.1 - - '@firebase/functions-compat@0.4.1(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6)': - dependencies: - '@firebase/app-compat': 0.5.6 - '@firebase/component': 0.7.0 - '@firebase/functions': 0.13.1(@firebase/app@0.14.6) - '@firebase/functions-types': 0.6.3 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@firebase/app' - - '@firebase/functions-types@0.6.3': {} - - '@firebase/functions@0.13.1(@firebase/app@0.14.6)': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/app-check-interop-types': 0.3.3 - '@firebase/auth-interop-types': 0.2.4 - '@firebase/component': 0.7.0 - '@firebase/messaging-interop-types': 0.2.3 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - - '@firebase/installations-compat@0.2.19(@firebase/app-compat@0.5.6)(@firebase/app-types@0.9.3)(@firebase/app@0.14.6)': - dependencies: - '@firebase/app-compat': 0.5.6 - '@firebase/component': 0.7.0 - '@firebase/installations': 0.6.19(@firebase/app@0.14.6) - '@firebase/installations-types': 0.5.3(@firebase/app-types@0.9.3) - '@firebase/util': 1.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@firebase/app' - - '@firebase/app-types' - - '@firebase/installations-types@0.5.3(@firebase/app-types@0.9.3)': - dependencies: - '@firebase/app-types': 0.9.3 - - '@firebase/installations@0.6.19(@firebase/app@0.14.6)': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/component': 0.7.0 - '@firebase/util': 1.13.0 - idb: 7.1.1 - tslib: 2.8.1 - - '@firebase/logger@0.5.0': - dependencies: - tslib: 2.8.1 - - '@firebase/messaging-compat@0.2.23(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6)': - dependencies: - '@firebase/app-compat': 0.5.6 - '@firebase/component': 0.7.0 - '@firebase/messaging': 0.12.23(@firebase/app@0.14.6) - '@firebase/util': 1.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@firebase/app' - - '@firebase/messaging-interop-types@0.2.3': {} - - '@firebase/messaging@0.12.23(@firebase/app@0.14.6)': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/component': 0.7.0 - '@firebase/installations': 0.6.19(@firebase/app@0.14.6) - '@firebase/messaging-interop-types': 0.2.3 - '@firebase/util': 1.13.0 - idb: 7.1.1 - tslib: 2.8.1 - - '@firebase/performance-compat@0.2.22(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6)': - dependencies: - '@firebase/app-compat': 0.5.6 - '@firebase/component': 0.7.0 - '@firebase/logger': 0.5.0 - '@firebase/performance': 0.7.9(@firebase/app@0.14.6) - '@firebase/performance-types': 0.2.3 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@firebase/app' - - '@firebase/performance-types@0.2.3': {} - - '@firebase/performance@0.7.9(@firebase/app@0.14.6)': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/component': 0.7.0 - '@firebase/installations': 0.6.19(@firebase/app@0.14.6) - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - web-vitals: 4.2.4 - - '@firebase/remote-config-compat@0.2.20(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6)': - dependencies: - '@firebase/app-compat': 0.5.6 - '@firebase/component': 0.7.0 - '@firebase/logger': 0.5.0 - '@firebase/remote-config': 0.7.0(@firebase/app@0.14.6) - '@firebase/remote-config-types': 0.5.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@firebase/app' - - '@firebase/remote-config-types@0.5.0': {} - - '@firebase/remote-config@0.7.0(@firebase/app@0.14.6)': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/component': 0.7.0 - '@firebase/installations': 0.6.19(@firebase/app@0.14.6) - '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - - '@firebase/storage-compat@0.4.0(@firebase/app-compat@0.5.6)(@firebase/app-types@0.9.3)(@firebase/app@0.14.6)': - dependencies: - '@firebase/app-compat': 0.5.6 - '@firebase/component': 0.7.0 - '@firebase/storage': 0.14.0(@firebase/app@0.14.6) - '@firebase/storage-types': 0.8.3(@firebase/app-types@0.9.3)(@firebase/util@1.13.0) - '@firebase/util': 1.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@firebase/app' - - '@firebase/app-types' - - '@firebase/storage-types@0.8.3(@firebase/app-types@0.9.3)(@firebase/util@1.13.0)': - dependencies: - '@firebase/app-types': 0.9.3 - '@firebase/util': 1.13.0 - - '@firebase/storage@0.14.0(@firebase/app@0.14.6)': - dependencies: - '@firebase/app': 0.14.6 - '@firebase/component': 0.7.0 - '@firebase/util': 1.13.0 - tslib: 2.8.1 - - '@firebase/util@1.13.0': - dependencies: - tslib: 2.8.1 - - '@firebase/webchannel-wrapper@1.0.5': {} - '@gorhom/bottom-sheet@5.2.8(@types/react-native@0.70.19)(@types/react@19.1.17)(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.6(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: '@gorhom/portal': 1.0.14(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -7158,17 +7080,17 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) - '@grpc/grpc-js@1.9.15': + '@hono/mcp@0.2.3(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))(hono-rate-limiter@0.4.2(hono@4.11.3))(hono@4.11.3)(zod@4.2.1)': dependencies: - '@grpc/proto-loader': 0.7.15 - '@types/node': 25.0.3 + '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.3)(zod@4.2.1) + hono: 4.11.3 + hono-rate-limiter: 0.4.2(hono@4.11.3) + pkce-challenge: 5.0.1 + zod: 4.2.1 - '@grpc/proto-loader@0.7.15': + '@hono/node-server@1.19.7(hono@4.11.3)': dependencies: - lodash.camelcase: 4.3.0 - long: 5.3.2 - protobufjs: 7.5.4 - yargs: 17.7.2 + hono: 4.11.3 '@humanfs/core@0.19.1': {} @@ -7401,41 +7323,278 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@napi-rs/wasm-runtime@0.2.12': + '@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1)': + dependencies: + '@hono/node-server': 1.19.7(hono@4.11.3) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 7.5.1(express@5.2.1) + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.2.1 + zod-to-json-schema: 3.25.1(zod@4.2.1) + transitivePeerDependencies: + - hono + - supports-color + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nolyfill/is-core-module@1.0.39': {} + + '@opentelemetry/api-logs@0.208.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.38.0 + + '@opentelemetry/instrumentation-amqplib@0.55.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.52.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.26.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-express@0.57.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.28.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.52.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.56.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.55.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.56.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.38.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.18.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.53.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.57.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.53.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.61.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.55.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.55.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.54.0(@opentelemetry/api@1.9.0)': dependencies: - '@emnapi/core': 1.7.1 - '@emnapi/runtime': 1.7.1 - '@tybys/wasm-util': 0.10.1 - optional: true + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@types/mysql': 2.15.27 + transitivePeerDependencies: + - supports-color - '@nolyfill/is-core-module@1.0.39': {} + '@opentelemetry/instrumentation-pg@0.61.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.6 + transitivePeerDependencies: + - supports-color - '@playwright/test@1.57.0': + '@opentelemetry/instrumentation-redis@0.57.0(@opentelemetry/api@1.9.0)': dependencies: - playwright: 1.57.0 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.38.0 + transitivePeerDependencies: + - supports-color - '@protobufjs/aspromise@1.1.2': {} + '@opentelemetry/instrumentation-tedious@0.27.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color - '@protobufjs/base64@1.1.2': {} + '@opentelemetry/instrumentation-undici@0.19.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + transitivePeerDependencies: + - supports-color - '@protobufjs/codegen@2.0.4': {} + '@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + import-in-the-middle: 2.0.1 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color - '@protobufjs/eventemitter@1.1.0': {} + '@opentelemetry/redis-common@0.38.2': {} - '@protobufjs/fetch@1.1.0': + '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)': dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/inquire': 1.1.0 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 - '@protobufjs/float@1.0.2': {} + '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 - '@protobufjs/inquire@1.1.0': {} + '@opentelemetry/semantic-conventions@1.38.0': {} - '@protobufjs/path@1.1.2': {} + '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@protobufjs/pool@1.1.0': {} + '@playwright/test@1.57.0': + dependencies: + playwright: 1.57.0 - '@protobufjs/utf8@1.1.0': {} + '@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color '@radix-ui/primitive@1.1.3': {} @@ -7629,33 +7788,23 @@ snapshots: optionalDependencies: '@types/react': 19.1.17 - '@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))': + '@react-native-async-storage/async-storage@1.24.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))': dependencies: merge-options: 3.0.4 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) - '@react-native-community/datetimepicker@8.4.4(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))': dependencies: - invariant: 2.2.4 - react: 19.1.0 + merge-options: 3.0.4 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) - optionalDependencies: - expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - - '@react-native-firebase/analytics@23.7.0(@react-native-firebase/app@23.7.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)))(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))': - dependencies: - '@react-native-firebase/app': 23.7.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)))(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - superstruct: 2.0.2 - '@react-native-firebase/app@23.7.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)))(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@react-native-community/datetimepicker@8.4.4(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: - firebase: 12.6.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))) + invariant: 2.2.4 react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) optionalDependencies: expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - transitivePeerDependencies: - - '@react-native-async-storage/async-storage' '@react-native/assets-registry@0.81.5': {} @@ -7957,6 +8106,73 @@ snapshots: '@sentry/core@10.12.0': {} + '@sentry/core@10.32.1': {} + + '@sentry/node-core@10.32.1(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)': + dependencies: + '@apm-js-collab/tracing-hooks': 0.3.1 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + '@sentry/core': 10.32.1 + '@sentry/opentelemetry': 10.32.1(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0) + import-in-the-middle: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@sentry/node@10.32.1': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-amqplib': 0.55.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-connect': 0.52.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-dataloader': 0.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-express': 0.57.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-fs': 0.28.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-generic-pool': 0.52.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-graphql': 0.56.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-hapi': 0.55.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-http': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-ioredis': 0.56.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-kafkajs': 0.18.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-knex': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-koa': 0.57.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-lru-memoizer': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongodb': 0.61.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongoose': 0.55.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql': 0.54.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql2': 0.55.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-pg': 0.61.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-redis': 0.57.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-tedious': 0.27.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-undici': 0.19.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + '@prisma/instrumentation': 6.19.0(@opentelemetry/api@1.9.0) + '@sentry/core': 10.32.1 + '@sentry/node-core': 10.32.1(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0) + '@sentry/opentelemetry': 10.32.1(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0) + import-in-the-middle: 2.0.1 + minimatch: 9.0.5 + transitivePeerDependencies: + - supports-color + + '@sentry/opentelemetry@10.32.1(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + '@sentry/core': 10.32.1 + '@sentry/react-native@7.2.0(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: '@sentry/babel-plugin-component-annotate': 4.3.0 @@ -7996,6 +8212,31 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@spotlightjs/spotlight@4.9.0(hono-rate-limiter@0.4.2(hono@4.11.3))': + dependencies: + '@hono/mcp': 0.2.3(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))(hono-rate-limiter@0.4.2(hono@4.11.3))(hono@4.11.3)(zod@4.2.1) + '@hono/node-server': 1.19.7(hono@4.11.3) + '@jridgewell/trace-mapping': 0.3.31 + '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.3)(zod@4.2.1) + '@sentry/core': 10.12.0 + '@sentry/node': 10.32.1 + anser: 2.3.5 + chalk: 5.6.2 + eventsource: 4.1.0 + fast-fuzzy: 1.12.0 + hono: 4.11.3 + launch-editor: 2.12.0 + logfmt: 1.4.0 + mcp-proxy: 5.12.5 + semver: 7.7.3 + uuidv7: 1.1.0 + yaml: 2.8.2 + zod: 4.2.1 + transitivePeerDependencies: + - '@cfworker/json-schema' + - hono-rate-limiter + - supports-color + '@supabase/auth-js@2.89.0': dependencies: tslib: 2.8.1 @@ -8074,6 +8315,10 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.0.3 + '@types/estree@1.0.8': {} '@types/graceful-fs@4.1.9': @@ -8107,10 +8352,24 @@ snapshots: '@types/json5@0.0.29': {} + '@types/mysql@2.15.27': + dependencies: + '@types/node': 25.0.3 + '@types/node@25.0.3': dependencies: undici-types: 7.16.0 + '@types/pg-pool@2.0.6': + dependencies: + '@types/pg': 8.15.6 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 25.0.3 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + '@types/phoenix@1.6.7': {} '@types/react-native@0.70.19': @@ -8124,6 +8383,10 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/tedious@4.0.14': + dependencies: + '@types/node': 25.0.3 + '@types/tough-cookie@4.0.5': {} '@types/ws@8.18.1': @@ -8136,6 +8399,8 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@types/zen-observable@0.8.3': {} + '@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -8317,11 +8582,20 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-globals@7.0.1: dependencies: acorn: 8.15.0 acorn-walk: 8.3.4 + acorn-import-attributes@1.9.5(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -8340,6 +8614,10 @@ snapshots: agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -8356,6 +8634,8 @@ snapshots: anser@1.4.10: {} + anser@2.3.5: {} + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -8619,6 +8899,20 @@ snapshots: big-integer@1.6.52: {} + body-parser@2.2.1: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.1 + on-finished: 2.4.1 + qs: 6.14.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} bplist-creator@0.1.0: @@ -8703,6 +8997,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + char-regex@1.0.2: {} chownr@3.0.0: {} @@ -8842,12 +9138,25 @@ snapshots: transitivePeerDependencies: - supports-color + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + core-js-compat@3.47.0: dependencies: browserslist: 4.28.1 + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + create-jest@29.7.0(@types/node@25.0.3): dependencies: '@jest/types': 29.6.3 @@ -9370,6 +9679,16 @@ snapshots: eventemitter3@5.0.1: {} + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + eventsource@4.1.0: + dependencies: + eventsource-parser: 3.0.6 + exec-async@2.2.0: {} execa@5.1.1: @@ -9691,18 +10010,55 @@ snapshots: exponential-backoff@3.1.3: {} + express-rate-limit@7.5.1(express@5.2.1): + dependencies: + express: 5.2.1 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.1 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} + fast-fuzzy@1.12.0: + dependencies: + graphemesplit: 2.6.0 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} fast-uri@3.1.0: {} - faye-websocket@0.11.4: - dependencies: - websocket-driver: 0.7.4 - fb-watchman@2.0.2: dependencies: bser: 2.1.1 @@ -9747,6 +10103,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -9757,72 +10124,6 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - firebase@12.6.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))): - dependencies: - '@firebase/ai': 2.6.0(@firebase/app-types@0.9.3)(@firebase/app@0.14.6) - '@firebase/analytics': 0.10.19(@firebase/app@0.14.6) - '@firebase/analytics-compat': 0.2.25(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6) - '@firebase/app': 0.14.6 - '@firebase/app-check': 0.11.0(@firebase/app@0.14.6) - '@firebase/app-check-compat': 0.4.0(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6) - '@firebase/app-compat': 0.5.6 - '@firebase/app-types': 0.9.3 - '@firebase/auth': 1.11.1(@firebase/app@0.14.6)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))) - '@firebase/auth-compat': 0.6.1(@firebase/app-compat@0.5.6)(@firebase/app-types@0.9.3)(@firebase/app@0.14.6)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))) - '@firebase/data-connect': 0.3.12(@firebase/app@0.14.6) - '@firebase/database': 1.1.0 - '@firebase/database-compat': 2.1.0 - '@firebase/firestore': 4.9.2(@firebase/app@0.14.6) - '@firebase/firestore-compat': 0.4.2(@firebase/app-compat@0.5.6)(@firebase/app-types@0.9.3)(@firebase/app@0.14.6) - '@firebase/functions': 0.13.1(@firebase/app@0.14.6) - '@firebase/functions-compat': 0.4.1(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6) - '@firebase/installations': 0.6.19(@firebase/app@0.14.6) - '@firebase/installations-compat': 0.2.19(@firebase/app-compat@0.5.6)(@firebase/app-types@0.9.3)(@firebase/app@0.14.6) - '@firebase/messaging': 0.12.23(@firebase/app@0.14.6) - '@firebase/messaging-compat': 0.2.23(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6) - '@firebase/performance': 0.7.9(@firebase/app@0.14.6) - '@firebase/performance-compat': 0.2.22(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6) - '@firebase/remote-config': 0.7.0(@firebase/app@0.14.6) - '@firebase/remote-config-compat': 0.2.20(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6) - '@firebase/storage': 0.14.0(@firebase/app@0.14.6) - '@firebase/storage-compat': 0.4.0(@firebase/app-compat@0.5.6)(@firebase/app-types@0.9.3)(@firebase/app@0.14.6) - '@firebase/util': 1.13.0 - transitivePeerDependencies: - - '@react-native-async-storage/async-storage' - - firebase@12.7.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))): - dependencies: - '@firebase/ai': 2.6.1(@firebase/app-types@0.9.3)(@firebase/app@0.14.6) - '@firebase/analytics': 0.10.19(@firebase/app@0.14.6) - '@firebase/analytics-compat': 0.2.25(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6) - '@firebase/app': 0.14.6 - '@firebase/app-check': 0.11.0(@firebase/app@0.14.6) - '@firebase/app-check-compat': 0.4.0(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6) - '@firebase/app-compat': 0.5.6 - '@firebase/app-types': 0.9.3 - '@firebase/auth': 1.12.0(@firebase/app@0.14.6)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))) - '@firebase/auth-compat': 0.6.2(@firebase/app-compat@0.5.6)(@firebase/app-types@0.9.3)(@firebase/app@0.14.6)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))) - '@firebase/data-connect': 0.3.12(@firebase/app@0.14.6) - '@firebase/database': 1.1.0 - '@firebase/database-compat': 2.1.0 - '@firebase/firestore': 4.9.3(@firebase/app@0.14.6) - '@firebase/firestore-compat': 0.4.3(@firebase/app-compat@0.5.6)(@firebase/app-types@0.9.3)(@firebase/app@0.14.6) - '@firebase/functions': 0.13.1(@firebase/app@0.14.6) - '@firebase/functions-compat': 0.4.1(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6) - '@firebase/installations': 0.6.19(@firebase/app@0.14.6) - '@firebase/installations-compat': 0.2.19(@firebase/app-compat@0.5.6)(@firebase/app-types@0.9.3)(@firebase/app@0.14.6) - '@firebase/messaging': 0.12.23(@firebase/app@0.14.6) - '@firebase/messaging-compat': 0.2.23(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6) - '@firebase/performance': 0.7.9(@firebase/app@0.14.6) - '@firebase/performance-compat': 0.2.22(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6) - '@firebase/remote-config': 0.7.0(@firebase/app@0.14.6) - '@firebase/remote-config-compat': 0.2.20(@firebase/app-compat@0.5.6)(@firebase/app@0.14.6) - '@firebase/storage': 0.14.0(@firebase/app@0.14.6) - '@firebase/storage-compat': 0.4.0(@firebase/app-compat@0.5.6)(@firebase/app-types@0.9.3)(@firebase/app@0.14.6) - '@firebase/util': 1.13.0 - transitivePeerDependencies: - - '@react-native-async-storage/async-storage' - flat-cache@4.0.1: dependencies: flatted: 3.3.3 @@ -9846,10 +10147,16 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded-parse@2.1.2: {} + + forwarded@0.2.0: {} + freeport-async@2.0.0: {} fresh@0.5.2: {} + fresh@2.0.0: {} + fs.realpath@1.0.0: {} fsevents@2.3.2: @@ -9951,6 +10258,11 @@ snapshots: graceful-fs@4.2.11: {} + graphemesplit@2.6.0: + dependencies: + js-base64: 3.7.8 + unicode-trie: 2.0.0 + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -9991,6 +10303,12 @@ snapshots: dependencies: react-is: 16.13.1 + hono-rate-limiter@0.4.2(hono@4.11.3): + dependencies: + hono: 4.11.3 + + hono@4.11.3: {} + hosted-git-info@7.0.2: dependencies: lru-cache: 10.4.3 @@ -10009,8 +10327,6 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-parser-js@0.5.10: {} - http-proxy-agent@5.0.0: dependencies: '@tootallnate/once': 2.0.0 @@ -10045,7 +10361,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 - idb@7.1.1: {} + iconv-lite@0.7.1: + dependencies: + safer-buffer: 2.1.2 ieee754@1.2.1: {} @@ -10062,6 +10380,13 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@2.0.1: + dependencies: + acorn: 8.15.0 + acorn-import-attributes: 1.9.5(acorn@8.15.0) + cjs-module-lexer: 1.4.3 + module-details-from-path: 1.0.4 + import-local@3.2.0: dependencies: pkg-dir: 4.2.0 @@ -10094,6 +10419,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + ipaddr.js@1.9.1: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -10185,6 +10512,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -10623,6 +10952,10 @@ snapshots: jimp-compact@0.16.1: {} + jose@6.1.3: {} + + js-base64@3.7.8: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -10679,6 +11012,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -10702,6 +11037,11 @@ snapshots: lan-network@0.1.7: {} + launch-editor@2.12.0: + dependencies: + picocolors: 1.1.1 + shell-quote: 1.8.3 + leven@3.1.0: {} levn@0.4.1: @@ -10794,8 +11134,6 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash.camelcase@4.3.0: {} - lodash.debounce@4.0.8: {} lodash.merge@4.6.2: {} @@ -10814,7 +11152,10 @@ snapshots: strip-ansi: 7.1.2 wrap-ansi: 9.0.2 - long@5.3.2: {} + logfmt@1.4.0: + dependencies: + split: 0.2.10 + through: 2.3.8 loose-envify@1.4.0: dependencies: @@ -10846,12 +11187,18 @@ snapshots: math-intrinsics@1.1.0: {} + mcp-proxy@5.12.5: {} + mdn-data@2.0.14: {} + media-typer@1.1.0: {} + memoize-one@5.2.1: {} memoize-one@6.0.0: {} + merge-descriptors@2.0.0: {} + merge-options@3.0.4: dependencies: is-plain-obj: 2.1.0 @@ -11046,6 +11393,10 @@ snapshots: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} mimic-fn@1.2.0: {} @@ -11078,6 +11429,8 @@ snapshots: mkdirp@1.0.4: {} + module-details-from-path@1.0.4: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -11100,6 +11453,8 @@ snapshots: negotiator@0.6.4: {} + negotiator@1.0.0: {} + nested-error-stacks@2.0.1: {} node-fetch@2.7.0: @@ -11258,6 +11613,8 @@ snapshots: p-try@2.2.0: {} + pako@0.2.9: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -11292,6 +11649,20 @@ snapshots: lru-cache: 11.2.4 minipass: 7.1.2 + path-to-regexp@8.3.0: {} + + pg-int8@1.0.1: {} + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -11304,6 +11675,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-dir@4.2.0: dependencies: find-up: 4.1.0 @@ -11334,6 +11707,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} prettier@3.7.4: {} @@ -11375,20 +11758,10 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 - protobufjs@7.5.4: - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.4 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 - '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.0 - '@protobufjs/path': 1.1.2 - '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.0 - '@types/node': 25.0.3 - long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 proxy-from-env@1.1.0: {} @@ -11402,6 +11775,10 @@ snapshots: qrcode-terminal@0.11.0: {} + qs@6.14.1: + dependencies: + side-channel: 1.1.0 + query-string@7.1.3: dependencies: decode-uri-component: 0.2.2 @@ -11417,6 +11794,13 @@ snapshots: range-parser@1.2.1: {} + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.1 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -11692,6 +12076,13 @@ snapshots: require-from-string@2.0.2: {} + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + requireg@0.2.2: dependencies: nested-error-stacks: 2.0.1 @@ -11750,6 +12141,20 @@ snapshots: dependencies: glob: 7.2.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -11807,6 +12212,22 @@ snapshots: transitivePeerDependencies: - supports-color + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serialize-error@2.1.0: {} serve-static@1.16.3: @@ -11818,6 +12239,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + server-only@0.0.1: {} set-function-length@1.2.2: @@ -11929,6 +12359,10 @@ snapshots: split-on-first@1.1.0: {} + split@0.2.10: + dependencies: + through: 2.3.8 + sprintf-js@1.0.3: {} stable-hash@0.0.5: {} @@ -12064,8 +12498,6 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 - superstruct@2.0.2: {} - supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -12125,6 +12557,10 @@ snapshots: throat@5.0.0: {} + through@2.3.8: {} + + tiny-inflate@1.0.3: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -12176,6 +12612,12 @@ snapshots: type-fest@0.7.1: {} + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -12237,6 +12679,11 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unique-string@2.0.0: dependencies: crypto-random-string: 2.0.0 @@ -12311,6 +12758,8 @@ snapshots: uuid@7.0.3: {} + uuidv7@1.1.0: {} + v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -12346,7 +12795,7 @@ snapshots: dependencies: defaults: 1.0.4 - web-vitals@4.2.4: {} + web-vitals@5.0.1: {} webidl-conversions@3.0.1: {} @@ -12354,14 +12803,6 @@ snapshots: webidl-conversions@7.0.0: {} - websocket-driver@0.7.4: - dependencies: - http-parser-js: 0.5.10 - safe-buffer: 5.2.1 - websocket-extensions: 0.1.4 - - websocket-extensions@0.1.4: {} - whatwg-encoding@2.0.0: dependencies: iconv-lite: 0.6.3 @@ -12480,6 +12921,8 @@ snapshots: xmlchars@2.2.0: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -12501,3 +12944,16 @@ snapshots: yargs-parser: 21.1.1 yocto-queue@0.1.0: {} + + zen-observable-ts@1.1.0: + dependencies: + '@types/zen-observable': 0.8.3 + zen-observable: 0.8.15 + + zen-observable@0.8.15: {} + + zod-to-json-schema@3.25.1(zod@4.2.1): + dependencies: + zod: 4.2.1 + + zod@4.2.1: {} diff --git a/types/analytics.ts b/types/analytics.ts index 05bca77c..04dfc6a8 100644 --- a/types/analytics.ts +++ b/types/analytics.ts @@ -1,6 +1,6 @@ // types/analytics.ts /** - * Firebase Analytics type definitions for Sobers. + * Amplitude Analytics type definitions for Sobers. * * These types define the contract for analytics events and user properties * tracked across all platforms (iOS, Android, web). @@ -10,22 +10,13 @@ /** * Allowed values for event parameters. - * Firebase Analytics supports strings, numbers, and booleans. + * Amplitude supports strings, numbers, booleans, and arrays. */ -export type EventParamValue = string | number | boolean | undefined; +export type EventParamValue = string | number | boolean | string[] | undefined; /** * Generic event parameters object. * All custom event parameters must use this interface. - * - * @example - * ```ts - * const params: EventParams = { - * task_id: '123', - * days_to_complete: 3, - * is_first_task: true, - * }; - * ``` */ export interface EventParams { [key: string]: EventParamValue; @@ -38,12 +29,13 @@ export interface EventParams { export type DaysSoberBucket = '0-7' | '8-30' | '31-90' | '91-180' | '181-365' | '365+'; /** - * User properties tracked in Firebase Analytics. + * Bucketed ranges for steps completed count. + */ +export type StepsCompletedBucket = '0' | '1-3' | '4-6' | '7-9' | '10-12'; + +/** + * User properties tracked in Amplitude Analytics. * These are set once and persist across sessions until changed. - * - * @remarks - * All properties are optional - only set properties that have values. - * Never include PII (email, name, exact dates) in user properties. */ export interface UserProperties { /** Bucketed sobriety duration for cohort analysis */ @@ -56,55 +48,83 @@ export interface UserProperties { theme_preference?: 'light' | 'dark' | 'system'; /** Authentication method used */ sign_in_method?: 'email' | 'google' | 'apple'; + /** Whether user completed onboarding */ + onboarding_completed?: boolean; + /** Current task streak count */ + task_streak_current?: number; + /** Bucketed 12-step progress */ + steps_completed_count?: StepsCompletedBucket; + /** Whether user has set a savings goal */ + savings_goal_set?: boolean; } /** - * Firebase web SDK configuration. - * These values come from Firebase Console > Project Settings > Web App. + * Amplitude configuration. + * Uses a single API key for all platforms. */ export interface AnalyticsConfig { apiKey: string; - projectId: string; - appId: string; - measurementId: string; } /** * Analytics event names used throughout the app. - * Using a const object ensures consistency and enables autocomplete. + * Using Title Case per Amplitude conventions for better dashboard rendering. */ export const AnalyticsEvents = { // Authentication - AUTH_SIGN_UP: 'auth_sign_up', - AUTH_LOGIN: 'auth_login', - AUTH_LOGOUT: 'auth_logout', + AUTH_SIGN_UP: 'Auth Sign Up', + AUTH_LOGIN: 'Auth Login', + AUTH_LOGOUT: 'Auth Logout', // Onboarding - ONBOARDING_STARTED: 'onboarding_started', - ONBOARDING_STEP_COMPLETED: 'onboarding_step_completed', - ONBOARDING_SOBRIETY_DATE_SET: 'onboarding_sobriety_date_set', - ONBOARDING_COMPLETED: 'onboarding_completed', + ONBOARDING_STARTED: 'Onboarding Started', + ONBOARDING_STEP_COMPLETED: 'Onboarding Step Completed', + ONBOARDING_SOBRIETY_DATE_SET: 'Onboarding Sobriety Date Set', + ONBOARDING_COMPLETED: 'Onboarding Completed', + ONBOARDING_SCREEN_VIEWED: 'Onboarding Screen Viewed', + ONBOARDING_FIELD_COMPLETED: 'Onboarding Field Completed', + ONBOARDING_ABANDONED: 'Onboarding Abandoned', // Core Features - SCREEN_VIEW: 'screen_view', - TASK_VIEWED: 'task_viewed', - TASK_STARTED: 'task_started', - TASK_COMPLETED: 'task_completed', - STEP_VIEWED: 'step_viewed', + SCREEN_VIEWED: 'Screen Viewed', + TASK_VIEWED: 'Task Viewed', + TASK_STARTED: 'Task Started', + TASK_COMPLETED: 'Task Completed', + TASK_CREATED: 'Task Created', + TASK_SKIPPED: 'Task Skipped', + TASK_STREAK_UPDATED: 'Task Streak Updated', + + // Steps (12-step) + STEP_VIEWED: 'Step Viewed', + STEP_STARTED: 'Step Started', + STEP_PROGRESS_SAVED: 'Step Progress Saved', + STEP_COMPLETED: 'Step Completed', // Milestones - MILESTONE_REACHED: 'milestone_reached', - MILESTONE_SHARED: 'milestone_shared', + MILESTONE_REACHED: 'Milestone Reached', + MILESTONE_SHARED: 'Milestone Shared', + MILESTONE_CELEBRATED: 'Milestone Celebrated', // Social - SPONSOR_CONNECTED: 'sponsor_connected', - SPONSOR_INVITE_SENT: 'sponsor_invite_sent', - SPONSOR_INVITE_ACCEPTED: 'sponsor_invite_accepted', - MESSAGE_SENT: 'message_sent', - - // Retention - APP_OPENED: 'app_opened', - DAILY_CHECK_IN: 'daily_check_in', + SPONSOR_CONNECTED: 'Sponsor Connected', + SPONSOR_INVITE_SENT: 'Sponsor Invite Sent', + SPONSOR_INVITE_ACCEPTED: 'Sponsor Invite Accepted', + SPONSEE_ADDED: 'Sponsee Added', + MESSAGE_SENT: 'Message Sent', + MESSAGE_READ: 'Message Read', + + // Engagement + APP_OPENED: 'App Opened', + APP_BACKGROUNDED: 'App Backgrounded', + APP_SESSION_STARTED: 'App Session Started', + DAILY_CHECK_IN: 'Daily Check In', + + // Settings + SETTINGS_CHANGED: 'Settings Changed', + + // Savings + SAVINGS_GOAL_SET: 'Savings Goal Set', + SAVINGS_UPDATED: 'Savings Updated', } as const; /** From 6682c9bd1b71589d5361ded74c97346ebb7873ac Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 29 Dec 2025 23:07:10 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`dev?= =?UTF-8?q?elop`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @BillChirico. * https://github.com/VolvoxCommunity/sobers/pull/232#issuecomment-3697755208 The following files were modified: * `lib/analytics-utils.ts` * `lib/analytics/index.ts` * `lib/analytics/platform.web.ts` --- lib/analytics-utils.ts | 6 +++--- lib/analytics/index.ts | 10 +++++----- lib/analytics/platform.web.ts | 11 ++++++----- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/analytics-utils.ts b/lib/analytics-utils.ts index adb5c33d..962cb401 100644 --- a/lib/analytics-utils.ts +++ b/lib/analytics-utils.ts @@ -72,10 +72,10 @@ function sanitizeValue( } /** - * Strip PII fields from event parameters. + * Removes personally identifiable information (PII) keys from event parameters. * * @param params - Event parameters to sanitize; may be undefined. - * @returns The parameters with PII keys removed; returns an empty object if `params` is falsy or sanitization fails. + * @returns Event parameters with PII keys removed. Returns an empty object if `params` is falsy or if sanitization yields a falsy result. */ export function sanitizeParams(params: EventParams | undefined): EventParams { if (!params) return {}; @@ -139,4 +139,4 @@ export function isDebugMode(): boolean { export function getAnalyticsEnvironment(): string { if (__DEV__) return 'development'; return process.env.EXPO_PUBLIC_APP_ENV || 'production'; -} +} \ No newline at end of file diff --git a/lib/analytics/index.ts b/lib/analytics/index.ts index f88ea5e0..70618001 100644 --- a/lib/analytics/index.ts +++ b/lib/analytics/index.ts @@ -60,9 +60,9 @@ let initializationPromise: Promise | null = null; let initializationState: 'pending' | 'completed' | 'skipped' | 'failed' | null = null; /** - * Initialize platform-specific analytics using the provided configuration. + * Initializes the platform-specific analytics implementation with the provided configuration. * - * @param config - Analytics configuration used to initialize platform analytics (must include `apiKey`) + * @param config - Analytics configuration; must include `apiKey` used to initialize analytics */ async function doInitialize(config: AnalyticsConfig): Promise { await initializePlatformAnalytics(config); @@ -71,9 +71,9 @@ async function doInitialize(config: AnalyticsConfig): Promise { /** * Initialize Amplitude Analytics for the app. * - * Starts analytics initialization using EXPO_PUBLIC_AMPLITUDE_API_KEY. Concurrent callers will share the same initialization process; initialization is skipped if configuration indicates analytics should not run. Initialization failures are logged and do not throw, allowing retries on subsequent calls. + * Starts initialization using EXPO_PUBLIC_AMPLITUDE_API_KEY. Concurrent callers share the same initialization attempt; initialization is skipped when configuration indicates analytics should not run. Initialization failures are logged and do not throw, allowing retries on subsequent calls. * - * @returns Nothing; resolves when initialization completes + * @returns Nothing. */ export async function initializeAnalytics(): Promise { // Already completed or skipped - return immediately @@ -241,4 +241,4 @@ export function __resetForTesting(): void { } initializationState = null; initializationPromise = null; -} +} \ No newline at end of file diff --git a/lib/analytics/platform.web.ts b/lib/analytics/platform.web.ts index ceac882d..536cbb81 100644 --- a/lib/analytics/platform.web.ts +++ b/lib/analytics/platform.web.ts @@ -136,9 +136,10 @@ export function setUserPropertiesPlatform(properties: UserProperties): void { } /** - * Tracks a screen view event. + * Record a screen view event with an associated screen name and optional screen class. + * * @param screenName - The name of the screen being viewed - * @param screenClass - Optional screen class. Defaults to screenName if not provided. + * @param screenClass - Optional screen class; when omitted, `screenName` is used */ export function trackScreenViewPlatform(screenName: string, screenClass?: string): void { trackEventPlatform(AnalyticsEvents.SCREEN_VIEWED, { @@ -148,9 +149,9 @@ export function trackScreenViewPlatform(screenName: string, screenClass?: string } /** - * Reset analytics client state for the web platform. + * Reset the Amplitude analytics client state for the web platform. * - * This is a no-op when analytics has not been initialized. Errors that occur during reset are logged and not rethrown. + * If analytics has not been initialized this function does nothing. Errors thrown by the underlying client are caught and logged and are not rethrown. */ export async function resetAnalyticsPlatform(): Promise { if (isDebugMode()) { @@ -181,4 +182,4 @@ export function __resetForTesting(): void { throw new Error('__resetForTesting should only be called in test environments'); } isInitialized = false; -} +} \ No newline at end of file From 38ead7bcfcd3098f0449dadc35bed7ff92a2ff69 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 29 Dec 2025 23:11:05 +0000 Subject: [PATCH 3/3] fix: add missing trailing newlines to files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docstrings PR removed trailing newlines from the end of files. This commit restores them to follow POSIX file format conventions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lib/analytics-utils.ts | 2 +- lib/analytics/index.ts | 2 +- lib/analytics/platform.web.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/analytics-utils.ts b/lib/analytics-utils.ts index 962cb401..e5d9b280 100644 --- a/lib/analytics-utils.ts +++ b/lib/analytics-utils.ts @@ -139,4 +139,4 @@ export function isDebugMode(): boolean { export function getAnalyticsEnvironment(): string { if (__DEV__) return 'development'; return process.env.EXPO_PUBLIC_APP_ENV || 'production'; -} \ No newline at end of file +} diff --git a/lib/analytics/index.ts b/lib/analytics/index.ts index 70618001..a7efe181 100644 --- a/lib/analytics/index.ts +++ b/lib/analytics/index.ts @@ -241,4 +241,4 @@ export function __resetForTesting(): void { } initializationState = null; initializationPromise = null; -} \ No newline at end of file +} diff --git a/lib/analytics/platform.web.ts b/lib/analytics/platform.web.ts index 536cbb81..1bf2b942 100644 --- a/lib/analytics/platform.web.ts +++ b/lib/analytics/platform.web.ts @@ -182,4 +182,4 @@ export function __resetForTesting(): void { throw new Error('__resetForTesting should only be called in test environments'); } isInitialized = false; -} \ No newline at end of file +}