Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ GestureHandlerRootView
└── ThemeProvider
└── APIProvider
└── AuthProvider
└── BottomSheetModalProvider
└── AnalyticsProvider
└── BottomSheetModalProvider
```

Never add a new provider outside `APIProvider` if it requires API access, or outside `AuthProvider` if it requires auth state.
Expand Down
81 changes: 81 additions & 0 deletions __tests__/components/analytics-screen-tracker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { usePathname } from 'expo-router';

import { AnalyticsScreenTracker } from '@/components/analytics-screen-tracker';
import { AnalyticsProvider } from '@/components/providers/analytics';
import type { AnalyticsProviderAdapter } from '@/lib/analytics';
import { render } from '@/lib/test-utils';

jest.mock('expo-router', () => ({
usePathname: jest.fn(),
}));

const mockUsePathname = usePathname as jest.MockedFunction<typeof usePathname>;

function createMockProvider(): AnalyticsProviderAdapter {
return {
init: jest.fn(),
trackEvent: jest.fn(),
trackScreen: jest.fn(),
};
}

describe('AnalyticsScreenTracker', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('tracks the current screen on mount', () => {
const provider = createMockProvider();
mockUsePathname.mockReturnValue('/sign-in');

render(
<AnalyticsProvider providers={[provider]}>
<AnalyticsScreenTracker />
</AnalyticsProvider>,
);

expect(provider.trackScreen).toHaveBeenCalledTimes(1);
expect(provider.trackScreen).toHaveBeenCalledWith('/sign-in', undefined);
});

it('tracks again when the route changes and not on unrelated re-renders', () => {
const provider = createMockProvider();
const providers = [provider];
mockUsePathname.mockReturnValue('/feed');

const { rerender } = render(
<AnalyticsProvider providers={providers}>
<AnalyticsScreenTracker />
</AnalyticsProvider>,
);

rerender(
<AnalyticsProvider providers={providers}>
<AnalyticsScreenTracker />
</AnalyticsProvider>,
);
expect(provider.trackScreen).toHaveBeenCalledTimes(1);

mockUsePathname.mockReturnValue('/settings');
rerender(
<AnalyticsProvider providers={providers}>
<AnalyticsScreenTracker />
</AnalyticsProvider>,
);

expect(provider.trackScreen).toHaveBeenCalledTimes(2);
expect(provider.trackScreen).toHaveBeenLastCalledWith('/settings', undefined);
});

it('renders nothing', () => {
mockUsePathname.mockReturnValue('/');

const { toJSON } = render(
<AnalyticsProvider providers={[createMockProvider()]}>
<AnalyticsScreenTracker />
</AnalyticsProvider>,
);

expect(toJSON()).toBeNull();
});
});
157 changes: 157 additions & 0 deletions __tests__/components/providers/analytics.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { renderHook, waitFor } from '@testing-library/react-native';

import { AnalyticsProvider, useAnalytics } from '@/components/providers/analytics';
import type { AnalyticsProviderAdapter } from '@/lib/analytics';
import { fireEvent, render, screen } from '@/lib/test-utils';
import { Text, TouchableOpacity, View } from '@/components/ui';

function createMockProvider(overrides: Partial<AnalyticsProviderAdapter> = {}) {
return {
init: jest.fn(),
trackEvent: jest.fn(),
trackScreen: jest.fn(),
identify: jest.fn(),
reset: jest.fn(),
...overrides,
} satisfies AnalyticsProviderAdapter;
}

function renderAnalytics(providers: ReadonlyArray<AnalyticsProviderAdapter>) {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AnalyticsProvider providers={providers}>{children}</AnalyticsProvider>
);
return renderHook(() => useAnalytics(), { wrapper });
}

describe('AnalyticsProvider', () => {
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
jest.restoreAllMocks();
});

it('initialises every provider once on mount', () => {
const first = createMockProvider();
const second = createMockProvider();

const { rerender } = renderAnalytics([first, second]);
rerender({});

expect(first.init).toHaveBeenCalledTimes(1);
expect(second.init).toHaveBeenCalledTimes(1);
});

it('fans out trackEvent to every provider', () => {
const first = createMockProvider();
const second = createMockProvider();
const { result } = renderAnalytics([first, second]);

result.current.trackEvent('BUTTON_PRESS', { accessibilityId: 'login-button' });

expect(first.trackEvent).toHaveBeenCalledWith('BUTTON_PRESS', {
accessibilityId: 'login-button',
});
expect(second.trackEvent).toHaveBeenCalledWith('BUTTON_PRESS', {
accessibilityId: 'login-button',
});
});

it('fans out trackScreen to every provider', () => {
const first = createMockProvider();
const second = createMockProvider();
const { result } = renderAnalytics([first, second]);

result.current.trackScreen('/feed', { tab: 'home' });

expect(first.trackScreen).toHaveBeenCalledWith('/feed', { tab: 'home' });
expect(second.trackScreen).toHaveBeenCalledWith('/feed', { tab: 'home' });
});

it('fans out identify and reset, skipping providers that do not implement them', () => {
const full = createMockProvider();
const minimal = createMockProvider({ identify: undefined, reset: undefined });
const { result } = renderAnalytics([full, minimal]);

result.current.identify('user-1', { plan: 'free' });
result.current.reset();

expect(full.identify).toHaveBeenCalledWith('user-1', { plan: 'free' });
expect(full.reset).toHaveBeenCalledTimes(1);
});

it('isolates provider failures so the other providers still receive the call', async () => {
const failing = createMockProvider({
trackEvent: jest.fn(() => {
throw new Error('vendor is down');
}),
});
const rejecting = createMockProvider({
trackScreen: jest.fn().mockRejectedValue(new Error('network')),
});
const healthy = createMockProvider();
const { result } = renderAnalytics([failing, rejecting, healthy]);

expect(() => {
result.current.trackEvent('BUTTON_PRESS', { accessibilityId: 'login-button' });
result.current.trackScreen('/feed');
}).not.toThrow();

expect(healthy.trackEvent).toHaveBeenCalledTimes(1);
expect(healthy.trackScreen).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(console.error).toHaveBeenCalledTimes(2);
});
});

it('rejects events that are not in the catalogue at compile time', () => {
const provider = createMockProvider();
const { result } = renderAnalytics([provider]);

// @ts-expect-error unknown event names are not allowed
result.current.trackEvent('NOT_IN_CATALOGUE', {});
// @ts-expect-error params must match the catalogue entry
result.current.trackEvent('BUTTON_PRESS', { wrong: true });

expect(provider.trackEvent).toHaveBeenCalledTimes(2);
});

it('throws when useAnalytics is used outside of AnalyticsProvider', () => {
expect(() => renderHook(() => useAnalytics())).toThrow(
'useAnalytics must be used within an AnalyticsProvider',
);
});
});

describe('useAnalytics in a component', () => {
it('tracks an event when the user presses a button', () => {
const provider = createMockProvider();

const TestComponent = () => {
const analytics = useAnalytics();
return (
<View>
<TouchableOpacity
testID="track-button"
onPress={() => analytics.trackEvent('BUTTON_PRESS', { accessibilityId: 'track-button' })}
>
<Text>Track</Text>
</TouchableOpacity>
</View>
);
};

render(
<AnalyticsProvider providers={[provider]}>
<TestComponent />
</AnalyticsProvider>,
);

fireEvent.press(screen.getByTestId('track-button'));

expect(provider.trackEvent).toHaveBeenCalledWith('BUTTON_PRESS', {
accessibilityId: 'track-button',
});
});
});
46 changes: 46 additions & 0 deletions __tests__/lib/analytics/console-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { consoleAnalyticsProvider } from '@/lib/analytics';

const devGlobal = globalThis as unknown as { __DEV__: boolean };

describe('consoleAnalyticsProvider', () => {
let infoSpy: jest.SpyInstance;
const originalDev = devGlobal.__DEV__;

beforeEach(() => {
infoSpy = jest.spyOn(console, 'info').mockImplementation(() => undefined);
});

afterEach(() => {
devGlobal.__DEV__ = originalDev;
infoSpy.mockRestore();
});

it('logs every call in development builds', () => {
devGlobal.__DEV__ = true;

consoleAnalyticsProvider.init();
consoleAnalyticsProvider.trackEvent('BUTTON_PRESS', { accessibilityId: 'login-button' });
consoleAnalyticsProvider.trackScreen('/feed');
consoleAnalyticsProvider.identify?.('user-1', { plan: 'free' });
consoleAnalyticsProvider.reset?.();

expect(infoSpy).toHaveBeenCalledTimes(5);
expect(infoSpy).toHaveBeenNthCalledWith(1, '[analytics]', 'init');
expect(infoSpy).toHaveBeenNthCalledWith(2, '[analytics]', 'event: BUTTON_PRESS', {
accessibilityId: 'login-button',
});
expect(infoSpy).toHaveBeenNthCalledWith(3, '[analytics]', 'screen: /feed');
expect(infoSpy).toHaveBeenNthCalledWith(4, '[analytics]', 'identify: user-1', { plan: 'free' });
expect(infoSpy).toHaveBeenNthCalledWith(5, '[analytics]', 'reset');
});

it('is a no-op outside development builds', () => {
devGlobal.__DEV__ = false;

consoleAnalyticsProvider.init();
consoleAnalyticsProvider.trackEvent('BUTTON_PRESS', { accessibilityId: 'login-button' });
consoleAnalyticsProvider.trackScreen('/feed');

expect(infoSpy).not.toHaveBeenCalled();
});
});
2 changes: 2 additions & 0 deletions agent_docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ Route guards are centralised in `src/app/_layout.tsx`. The `GuardedStack` compon
| Translation strings | `src/translations/en.json` |
| Environment config | `src/lib/env.js` (access via `Env` from `@/lib/env`) |
| General persistent storage | `src/lib/storage.tsx` (MMKV) |
| Analytics — event catalogue & provider contract | `src/lib/analytics/types.ts` |
| Analytics — context, `useAnalytics()`, screen tracking | `src/components/providers/analytics.tsx`, `src/components/analytics-screen-tracker.tsx` |
| Global TypeScript types | `src/types/index.ts` |

---
Expand Down
5 changes: 5 additions & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ export default defineConfig({
label: 'Storage',
link: '/guides/storage/',
},
{
label: 'Analytics',
link: '/guides/analytics/',
badge: 'new',
},
{
label: 'Upgrade Dependencies',
link: '/guides/upgrading-deps/',
Expand Down
Loading
Loading