Skip to content
Merged
Show file tree
Hide file tree
Changes from 46 commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
8203744
refactor(analytics): use Metro platform file resolution for cross-pla…
BillChirico Dec 8, 2025
36dc454
chore(deps): add expo-glass-effect for liquid glass effects
BillChirico Dec 8, 2025
9150485
feat(theme): add glass effect properties to theme system
BillChirico Dec 8, 2025
ffa2d70
feat(components): add GlassView with fallback for non-iOS platforms
BillChirico Dec 8, 2025
3c62c4b
test(GlassView): add native glass path tests
BillChirico Dec 8, 2025
3e5f672
fix(GlassView): remove explicit JSX.Element return type
BillChirico Dec 8, 2025
e951b5d
fix(eslint): add exception for expo-glass-effect import resolution
BillChirico Dec 8, 2025
d4c1fe5
refactor(analytics): rename impl.* to platform.* following React Nati…
BillChirico Dec 8, 2025
423867a
test(analytics): add tests for fallback platform and utility functions
BillChirico Dec 8, 2025
bc988a1
fix(journey): center Days Sober display by removing icon
BillChirico Dec 8, 2025
4f43dcc
Update app.config.ts
BillChirico Dec 9, 2025
7739fea
chore(gitignore): remove Firebase config files from .gitignore
BillChirico Dec 9, 2025
8e9561c
refactor(settings): update sign out behavior and improve modal handling
BillChirico Dec 9, 2025
a92575e
fix(analytics): make platform functions synchronous to match public API
BillChirico Dec 9, 2025
fc1b7ff
fix(analytics): retrieve analytics instance on re-initialization
BillChirico Dec 9, 2025
ff5e30d
fix(analytics): add warning for incomplete Firebase config
BillChirico Dec 9, 2025
f066f9d
refactor(plugins): improve withModularHeaders robustness
BillChirico Dec 9, 2025
7ed7b18
test(theme): add dark theme tests for glass properties
BillChirico Dec 9, 2025
d2788a1
test(analytics): update tests for synchronous fire-and-forget API
BillChirico Dec 9, 2025
bcf88e4
fix(analytics): make initialization platform-aware for native builds
BillChirico Dec 9, 2025
09acb40
security(firebase): remove config files from version control
BillChirico Dec 9, 2025
b81de4b
feat(firebase): add config plugin for EAS secret injection
BillChirico Dec 9, 2025
0ad364a
feat(firebase): support both file and string EAS secret types
BillChirico Dec 9, 2025
0426c19
refactor(firebase): simplify plugin to use file secrets only
BillChirico Dec 9, 2025
4d5e128
feat(firebase): add firebase.json for analytics configuration
BillChirico Dec 9, 2025
1b8d140
refactor(analytics): migrate native to Firebase v22 modular API
BillChirico Dec 9, 2025
d578839
chore: cleanup test utilities and fix config plugin
BillChirico Dec 9, 2025
5190d76
fix(plugin): correct variable reference in withModularHeaders
BillChirico Dec 9, 2025
0c485b1
fix(analytics): use logEvent for screen views to avoid deprecation wa…
BillChirico Dec 9, 2025
c0ced95
fix(analytics): defer getAnalytics() call to prevent module-scope crash
BillChirico Dec 9, 2025
78d4575
fix(analytics): enable analytics collection in production, not just d…
BillChirico Dec 9, 2025
7cf2215
fix(ci): prevent duplicate workflow runs on PR pushes
BillChirico Dec 9, 2025
c87a6c0
fix(ci): streamline push trigger for main branch
BillChirico Dec 9, 2025
f169778
fix(plugin): handle EAS FILE_BASE64 secrets correctly
BillChirico Dec 9, 2025
f602734
docs(analytics): add enhanced docstrings for platform modules
coderabbitai[bot] Dec 9, 2025
76012e4
chore: merge main into develop to resolve PR conflicts
BillChirico Dec 9, 2025
e9ea511
fix(firebase): use EAS secret paths for googleServicesFile config
BillChirico Dec 9, 2025
9c225ad
fix(analytics): prevent duplicate Firebase connections and add SEO me…
BillChirico Dec 9, 2025
30ef8be
📝 Add docstrings to `develop` (#105)
coderabbitai[bot] Dec 9, 2025
decd8b3
chore: merge main into develop to resolve PR conflicts
BillChirico Dec 9, 2025
d664023
chore: merge origin/develop - resolve withFirebaseConfig conflict
BillChirico Dec 9, 2025
1b43b8f
refactor(mocks): add __esModule flag for ES module interop
BillChirico Dec 9, 2025
9e7fd73
fix(analytics): add try/catch and improve resetAnalytics docs
BillChirico Dec 9, 2025
6495b2a
fix(analytics): reset flag on failure and clarify config docs
BillChirico Dec 9, 2025
997522b
docs(plugin): clarify secretValue supports raw content in JSDoc
BillChirico Dec 9, 2025
ca8ecd4
fix(seo): use absolute URLs for OG and Twitter image meta tags
BillChirico Dec 9, 2025
8e641fc
fix(seo): render meta tags unconditionally for crawlers
BillChirico Dec 9, 2025
d591b4f
CodeRabbit Generated Unit Tests: Add comprehensive Jest tests for web…
coderabbitai[bot] Dec 9, 2025
e93ab80
fix(analytics): handle circular references in sanitizeParamsForLogging
BillChirico Dec 9, 2025
187dbec
refactor(analytics): use Promise-based initialization to prevent race…
BillChirico Dec 9, 2025
b5c4bc7
refactor(analytics): use Promise-based initialization in index.ts
BillChirico Dec 9, 2025
a35b9a0
docs(mocks): fix JSDoc type annotations in expoRouterHead mock
BillChirico Dec 9, 2025
025e1b5
fix(analytics): add __resetForTesting to index.ts and update tests
BillChirico Dec 9, 2025
b66979a
docs: clarify secretValue JSDoc and simplify mock types
BillChirico Dec 9, 2025
e494004
test(analytics): add coverage for concurrent initialization and debug…
BillChirico Dec 9, 2025
2e7da0c
Merge branch 'main' into develop
BillChirico Dec 9, 2025
568980d
fix(analytics): correctly handle shared objects in param sanitization
BillChirico Dec 9, 2025
e958a54
chore(test): remove unused variable in analytics test
BillChirico Dec 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions __mocks__/expoRouterHead.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Mock for expo-router/head module to prevent ESM parsing errors in Jest tests.
* This provides a simple passthrough component that renders children.
*/

const React = require('react');

/**
* Renders the provided children inside a React Fragment.
* @param {object} props - Component props.
* @param {import('react').ReactNode} props.children - Elements to render inside the fragment.
* @returns {import('react').ReactElement} A React Fragment containing the given children.
Comment thread
BillChirico marked this conversation as resolved.
Outdated
*/
function Head({ children }) {
return React.createElement(React.Fragment, null, children);
}

module.exports = Head;
module.exports.default = Head;
Comment thread
BillChirico marked this conversation as resolved.
module.exports.__esModule = true;
3 changes: 3 additions & 0 deletions __tests__/lib/analytics.web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
setUserPropertiesPlatform as setUserPropertiesWeb,
trackScreenViewPlatform as trackScreenViewWeb,
resetAnalyticsPlatform as resetAnalyticsWeb,
__resetForTesting,
} from '@/lib/analytics/platform.web';

const mockLoggerWarn = jest.fn();
Expand Down Expand Up @@ -62,6 +63,8 @@ jest.mock('firebase/analytics', () => ({

describe('Web Analytics', () => {
beforeEach(() => {
// Reset module state to allow re-initialization between tests
__resetForTesting();
jest.clearAllMocks();
(getApps as jest.Mock).mockReturnValue([]);
(isSupported as jest.Mock).mockResolvedValue(true);
Expand Down
21 changes: 21 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
useNavigationContainerRef,
useRootNavigationState,
} from 'expo-router';
import Head from 'expo-router/head';
import { StatusBar } from 'expo-status-bar';
import { useFrameworkReady } from '@/hooks/useFrameworkReady';
import { AuthProvider, useAuth } from '@/contexts/AuthContext';
Expand Down Expand Up @@ -133,6 +134,26 @@ function RootLayoutNav() {

return (
<>
<Head>
<title>Sobriety Waypoint</title>
<meta name="description" content="Your companion on the journey to recovery" />

{/* Open Graph / Facebook */}
<meta property="og:type" content="website" />
<meta property="og:title" content="Sobriety Waypoint" />
<meta property="og:description" content="Your companion on the journey to recovery" />
<meta property="og:site_name" content="Sobriety Waypoint" />
<meta property="og:image" content="https://sobrietywaypoint.com/assets/images/banner.png" />
Comment thread
BillChirico marked this conversation as resolved.
Outdated

{/* Twitter */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Sobriety Waypoint" />
<meta name="twitter:description" content="Your companion on the journey to recovery" />
<meta
name="twitter:image"
content="https://sobrietywaypoint.com/assets/images/banner.png"
/>
</Head>
Comment thread
BillChirico marked this conversation as resolved.
Outdated
Comment thread
BillChirico marked this conversation as resolved.
Outdated
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="login" />
<Stack.Screen name="signup" />
Expand Down
Binary file added assets/images/banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module.exports = {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
},
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|@sentry|native-base|react-native-svg|lucide-react-native|@supabase)',
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|expo-router|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|@sentry|native-base|react-native-svg|lucide-react-native|@supabase)',
],
setupFiles: ['<rootDir>/jest.setup.js'],
testMatch: ['**/__tests__/**/*.(spec|test).[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'],
Expand All @@ -22,6 +22,8 @@ module.exports = {
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
// Mock expo virtual modules to prevent ESM parsing errors
'^expo/virtual/(.*)$': '<rootDir>/__mocks__/expoVirtualMock.js',
// Mock expo-router/head to prevent ESM parsing errors
'^expo-router/head$': '<rootDir>/__mocks__/expoRouterHead.js',
},
collectCoverageFrom: [
'app/**/*.{ts,tsx}',
Expand Down
52 changes: 40 additions & 12 deletions lib/analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ import {
export { AnalyticsEvents, type AnalyticsEventName } from '@/types/analytics';
export { calculateDaysSoberBucket } from '@/lib/analytics-utils';

// =============================================================================
// Module State
// =============================================================================
/**
* Guard flag to prevent multiple initialization attempts at the index level.
* This provides defense-in-depth against hot reload and React Strict Mode double-invocations.
*/
let isInitialized = false;

Comment thread
BillChirico marked this conversation as resolved.
/**
* Initialize Firebase Analytics for the app.
*
Expand All @@ -58,6 +67,16 @@ export { calculateDaysSoberBucket } from '@/lib/analytics-utils';
* ```
*/
export async function initializeAnalytics(): Promise<void> {
// Defense-in-depth: prevent re-initialization at the public API level
if (isInitialized) {
if (isDebugMode()) {
logger.debug('Analytics already initialized, skipping', {
category: LogCategory.ANALYTICS,
});
}
return;
}

if (!shouldInitializeAnalytics()) {
if (isDebugMode()) {
logger.warn('Firebase not configured - analytics disabled', {
Expand All @@ -67,6 +86,9 @@ export async function initializeAnalytics(): Promise<void> {
return;
}

// Mark as initialized before async operations to prevent race conditions
isInitialized = true;

// On native, Firebase reads config from GoogleService-Info.plist / google-services.json
// On web, we need explicit configuration via environment variables
const config: AnalyticsConfig = {
Expand All @@ -87,7 +109,16 @@ export async function initializeAnalytics(): Promise<void> {
});
}

await initializePlatformAnalytics(config);
try {
await initializePlatformAnalytics(config);
} catch (error) {
// Reset flag to allow retry on critical failures
isInitialized = false;
Comment thread
BillChirico marked this conversation as resolved.
Outdated
const err = error instanceof Error ? error : new Error(String(error));
logger.error('Failed to initialize analytics platform', err, {
category: LogCategory.ANALYTICS,
});
}
Comment thread
BillChirico marked this conversation as resolved.
Outdated
}

/**
Expand Down Expand Up @@ -156,27 +187,24 @@ export function setUserProperties(properties: UserProperties): void {
}

/**
* Tracks a screen view event.
*
* This is typically called automatically by navigation tracking,
* but can be called manually for non-standard screens.
* Record a screen view for analytics.
*
* @param screenName - The name of the screen
* @param screenClass - Optional screen class name
* Typically invoked automatically by navigation tracking; call manually for non-standard or ephemeral screens.
*
* @example
* ```ts
* trackScreenView('HomeScreen', 'TabScreen');
* ```
* @param screenName - The logical name of the screen (e.g., "Home", "Settings")
* @param screenClass - Optional class or category of the screen (e.g., "TabScreen")
*/
export function trackScreenView(screenName: string, screenClass?: string): void {
trackScreenViewPlatform(screenName, screenClass);
}

/**
* Reset analytics state for the current user.
* Resets analytics state for the current user.
*
* Clears the analytics user identifier and any user-specific analytics data.
Comment thread
BillChirico marked this conversation as resolved.
*
* Note: This does NOT reset the analytics initialization state (`isInitialized` flag).
* Analytics initialization persists after reset.
*/
export async function resetAnalytics(): Promise<void> {
await resetAnalyticsPlatform();
Expand Down
35 changes: 15 additions & 20 deletions lib/analytics/platform.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,9 @@ function getAnalyticsInstance(): ReturnType<typeof getAnalytics> | null {
}

/**
* Initialize Firebase Analytics for native platforms.
* Initializes Firebase Analytics for native platforms using the bundled native configuration.
*
* On iOS and Android the native Firebase configuration files are used; the
* optional `config` argument is ignored by the native implementation.
* The optional `_config` argument is ignored on iOS and Android.
*
* @param _config - Optional analytics configuration; ignored on native platforms
*/
Expand Down Expand Up @@ -145,10 +144,10 @@ function hashUserIdForLogging(input: string | null): string | null {
}

/**
* Sets the user ID for analytics.
* Set the analytics user ID for the current session.
*
* 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.
* 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
*/
Expand Down Expand Up @@ -213,15 +212,13 @@ function sanitizeUserPropertiesForLogging(
}

/**
* Sets user properties for analytics.
*
* 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.
* Set analytics user properties for the native platform.
*
* Firebase requires user properties to be string or null. Boolean values are
* converted to strings ('true'/'false') to preserve semantic meaning.
* 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.
*
* @param properties - Object mapping property names to string values; use `null` to clear a property
* @param properties - Mapping of user property names to string, boolean, `null`, or `undefined` values
*/
export function setUserPropertiesPlatform(properties: UserProperties): void {
if (isDebugMode()) {
Expand Down Expand Up @@ -255,13 +252,9 @@ export function setUserPropertiesPlatform(properties: UserProperties): void {
}

/**
* Tracks a screen view event.
* Record a screen view in analytics.
*
* Uses logEvent with 'screen_view' instead of the dedicated logScreenView function
* to avoid deprecation warnings in React Native Firebase v22+.
*
* 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.
* 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
Expand Down Expand Up @@ -295,7 +288,9 @@ export function trackScreenViewPlatform(screenName: string, screenClass?: string
}

/**
* Clear analytics state and stored analytics data (commonly used on user logout).
* Clear the native Firebase Analytics instance state and stored analytics data, typically used on user logout.
*
* If Firebase Analytics is unavailable, logs a warning and returns without error. Any errors encountered while resetting are logged and not rethrown.
*/
export async function resetAnalyticsPlatform(): Promise<void> {
try {
Expand Down
6 changes: 2 additions & 4 deletions lib/analytics/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,9 @@ import type { EventParams, UserProperties, AnalyticsConfig } from '@/types/analy
import { logger, LogCategory } from '@/lib/logger';

/**
* Provide a fallback analytics initializer for unsupported platforms.
* Logs a warning that the fallback analytics implementation is active on unsupported platforms.
*
* Logs a warning that the fallback implementation is in use and performs no analytics initialization.
*
* @param _config - Optional analytics configuration; this implementation ignores the value
* @param _config - Optional analytics configuration; ignored by the fallback implementation
*/
export async function initializePlatformAnalytics(_config?: AnalyticsConfig): Promise<void> {
logger.warn('Analytics: Using fallback implementation (platform not supported)', {
Expand Down
71 changes: 53 additions & 18 deletions lib/analytics/platform.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ let analytics: Analytics | null = null;
let app: FirebaseApp | null = null;
let vercelAnalytics: VercelAnalytics | null = null;

/**
* Guard flag to prevent multiple initialization attempts.
* This prevents duplicate Firebase connections during hot reload or React Strict Mode.
*/
let isInitialized = false;

// =============================================================================
// Constants (continued)
// =============================================================================
Expand Down Expand Up @@ -335,25 +341,31 @@ function dispatchEvent(eventName: string, params?: EventParams): void {
// Main Logic
// =============================================================================
/**
* Initializes analytics providers for web.
* Initialize configured analytics providers for the web platform.
*
* Initializes Firebase Analytics and/or Vercel Analytics based on configuration.
* Firebase requires explicit configuration; Vercel Analytics auto-detects if installed.
* This function is idempotent: once initialized, subsequent calls are no-ops.
* If Firebase analytics is enabled, `config` must contain the Firebase app settings.
*
* @param config - Firebase configuration (required if Firebase is enabled)
* @returns Promise that resolves when initialization is complete
*
* @example
* ```ts
* await initializePlatformAnalytics({
* apiKey: 'your-api-key',
* projectId: 'your-project',
* appId: 'your-app-id',
* measurementId: 'G-XXXXXXXXXX'
* });
* ```
* @param config - Firebase configuration required when Firebase analytics is enabled.
* If Firebase is enabled but config is missing or invalid (empty apiKey, projectId, or appId),
* Firebase initialization will fail silently and be logged. Subsequent analytics calls
* will be no-ops until the next successful initialization (requires page reload or
* calling `__resetForTesting()` in tests).
*/
export async function initializePlatformAnalytics(config: AnalyticsConfig): Promise<void> {
// Guard against multiple initialization attempts (hot reload, React Strict Mode, etc.)
if (isInitialized) {
if (isDebugMode()) {
logger.debug('Analytics already initialized, skipping re-initialization', {
category: LogCategory.ANALYTICS,
});
}
return;
}

// Mark as initialized immediately to prevent race conditions
isInitialized = true;
Comment thread
BillChirico marked this conversation as resolved.
Outdated

// Initialize Firebase if enabled
if (IS_FIREBASE_ENABLED) {
try {
Expand Down Expand Up @@ -392,11 +404,14 @@ export async function initializePlatformAnalytics(config: AnalyticsConfig): Prom
}
}
} catch (error) {
// Reset flag to allow retry on critical failures
isInitialized = false;
Comment thread
BillChirico marked this conversation as resolved.
Outdated
logger.error(
'Failed to initialize Firebase Analytics',
error instanceof Error ? error : new Error(String(error)),
{ category: LogCategory.ANALYTICS }
);
return;
Comment thread
BillChirico marked this conversation as resolved.
Outdated
Comment thread
BillChirico marked this conversation as resolved.
Outdated
}
}

Expand Down Expand Up @@ -554,10 +569,9 @@ export function trackScreenViewPlatform(screenName: string, screenClass?: string
}

/**
* Resets analytics for logout.
* Reset analytics state for the current user.
*
* Clears user ID and user properties in Firebase Analytics.
* Vercel Analytics does not support reset operations.
* Clears the Firebase Analytics user ID and user properties. No-op for Vercel Analytics because it does not expose a reset API.
*/
export async function resetAnalyticsPlatform(): Promise<void> {
if (isDebugMode()) {
Expand All @@ -567,6 +581,27 @@ export async function resetAnalyticsPlatform(): Promise<void> {
setUserIdPlatform(null);
}

// =============================================================================
// Testing Utilities
// =============================================================================
/**
* Reset the module's analytics initialization state for tests.
*
* Clears the internal initialization guard and removes stored Firebase and Vercel analytics instances so the module can be re-initialized in a test.
*
* @throws Will throw an Error if called when NODE_ENV is not 'test'.
* @internal
*/
export function __resetForTesting(): void {
if (process.env.NODE_ENV !== 'test') {
throw new Error('__resetForTesting should only be called in test environments');
}
isInitialized = false;
analytics = null;
app = null;
vercelAnalytics = null;
}

// =============================================================================
// Exports
// =============================================================================
Expand Down
Loading
Loading