diff --git a/components/AvalancheCenterSelector.tsx b/components/AvalancheCenterSelector.tsx index e3865ad6..ec4e4a25 100644 --- a/components/AvalancheCenterSelector.tsx +++ b/components/AvalancheCenterSelector.tsx @@ -8,7 +8,7 @@ import {avalancheCenterList, AvalancheCenters} from 'components/avalancheCenterL import {AvalancheCenterList} from 'components/content/AvalancheCenterList'; import {incompleteQueryState, QueryState} from 'components/content/QueryState'; import {useAllAvalancheCenterMetadata} from 'hooks/useAllAvalancheCenterMetadata'; -import {useAnalytics} from 'hooks/useAnalytics'; +import {CenterSwitchOrigin, useAnalytics} from 'hooks/useAnalytics'; import {useAvalancheCenterCapabilities} from 'hooks/useAvalancheCenterCapabilities'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {MainStackNavigationProps, MainStackParamList} from 'routes'; @@ -24,15 +24,17 @@ export const AvalancheCenterSelector: React.FunctionComponent<{ const capabilities = capabilitiesResult.data; const whichCenters = route.params.debugMode ? AvalancheCenters.AllCenters : AvalancheCenters.SupportedCenters; const metadataResults = useAllAvalancheCenterMetadata(capabilities, whichCenters); + const analytics = useAnalytics(); + const setAvalancheCenterWrapper = React.useCallback( (center: AvalancheCenterID) => { + analytics.captureCenterSwitch(currentCenterId, center, CenterSwitchOrigin.CenterSelectorView); setAvalancheCenter(center); navigation.goBack(); }, - [setAvalancheCenter, navigation], + [setAvalancheCenter, currentCenterId, navigation, analytics], ); - const analytics = useAnalytics(); const recordAnalytics = useCallback(() => { analytics.screen('centerSelector'); diff --git a/components/content/navigation/NavigationHeader.tsx b/components/content/navigation/NavigationHeader.tsx index f94a3efe..149f2eff 100644 --- a/components/content/navigation/NavigationHeader.tsx +++ b/components/content/navigation/NavigationHeader.tsx @@ -4,6 +4,7 @@ import {NativeStackHeaderProps} from '@react-navigation/native-stack'; import {HStack, View} from 'components/core'; import {GenerateObservationShareLink} from 'components/observations/ObservationUrlMapping'; import {Title3Black} from 'components/text'; +import {getPresentedFromForAnalytics, useAnalytics} from 'hooks/useAnalytics'; import {logger} from 'logger'; import {usePreferences} from 'Preferences'; import React, {useCallback} from 'react'; @@ -16,6 +17,8 @@ export const NavigationHeader: React.FunctionComponent = const {preferences} = usePreferences(); const centerId = preferences.center; + const analytics = useAnalytics(); + let share: boolean = false; let firstOpen: boolean = false; let shareCenterId: AvalancheCenterID = centerId; @@ -69,10 +72,12 @@ export const NavigationHeader: React.FunctionComponent = return; } + analytics.capture('shareButtonPressed', {presentedFrom: getPresentedFromForAnalytics(navigation)}); + Share.share({ message: shareUrl, }).catch((error: object) => logger.error(error, 'share button failed')); - }, [shareUrl]); + }, [shareUrl, analytics, navigation]); return ( // Setting the top padding to insets.top correctly aligns the view underneath the notches on iPhone. Trying to set the padding ourselves could lead to unexpected behavior diff --git a/components/map/AvalancheForecastMapView.tsx b/components/map/AvalancheForecastMapView.tsx index 99f93c9a..5c0b141d 100644 --- a/components/map/AvalancheForecastMapView.tsx +++ b/components/map/AvalancheForecastMapView.tsx @@ -18,6 +18,7 @@ import {defaultMapRegionForGeometries, insetViewportBounds, regionBoundsVisible} import {AvalancheForecastZoneCards} from 'components/map/AvalancheForecastZoneCards'; import {TopElementMeasurments} from 'components/map/AvalancheForecastZoneMap'; import {Position} from 'geojson'; +import {CenterSwitchOrigin, useAnalytics} from 'hooks/useAnalytics'; interface AvalancheForecastMapViewProps { preferredCenterId: AvalancheCenterID; @@ -43,6 +44,7 @@ export const AvalancheForecastMapView: React.FunctionComponent(LoggerContext); const {setPreferences} = usePreferences(); + const analytics = useAnalytics(); const {isInNoCenterExperience, setIsInNoCenterExperience, initialMapCamera, saveMapCamera} = useMapPersistence(); const navigation = useNavigation(); @@ -72,6 +74,7 @@ export const AvalancheForecastMapView: React.FunctionComponent zones.filter(zone => zone.center_id === preferredCenterId), [zones, preferredCenterId]); diff --git a/components/observations/ObservationForm.tsx b/components/observations/ObservationForm.tsx index 0f444d5b..bb95e30e 100644 --- a/components/observations/ObservationForm.tsx +++ b/components/observations/ObservationForm.tsx @@ -30,7 +30,7 @@ import {UploaderState, getUploader} from 'components/observations/uploader/Obser import {TaskStatus} from 'components/observations/uploader/Task'; import {Body, BodySemibold, Title3Semibold} from 'components/text'; import helpStrings from 'content/helpStrings'; -import {useAnalytics} from 'hooks/useAnalytics'; +import {getPresentedFromForAnalytics, useAnalytics} from 'hooks/useAnalytics'; import {useAvalancheCenterCapabilities} from 'hooks/useAvalancheCenterCapabilities'; import {useAvalancheCenterMetadata} from 'hooks/useAvalancheCenterMetadata'; import {useKeyboardBehavior} from 'hooks/useKeyboardBehavior'; @@ -240,12 +240,14 @@ export const ObservationForm: React.FC<{ if (!formContext.getValues('instability.avalanches_observed') && formContext.getValues('avalanches').length > 0) { formContext.setValue('avalanches', []); } + + analytics.capture('submitObsButtonPressed', {presentedFrom: getPresentedFromForAnalytics(navigation)}); // Force validation errors to show up on fields that haven't been visited yet await formContext.trigger(); // Then try to submit the form void formContext.handleSubmit(onSubmitHandler, onSubmitErrorHandler)(); })(); - }, [formContext, onSubmitHandler, onSubmitErrorHandler]); + }, [formContext, analytics, navigation, onSubmitHandler, onSubmitErrorHandler]); const onCloseHandler = useCallback(() => { formContext.reset(); diff --git a/hooks/useAnalytics.ts b/hooks/useAnalytics.ts index 8afe16d7..7abd25fd 100644 --- a/hooks/useAnalytics.ts +++ b/hooks/useAnalytics.ts @@ -1,17 +1,43 @@ import {useContext, useMemo} from 'react'; +import {NavigationState} from '@react-navigation/native'; import {Logger} from 'browser-bunyan'; import PostHog, {usePostHog} from 'posthog-react-native'; import {LoggerContext} from 'loggerContext'; +import {AvalancheCenterID} from 'types/nationalAvalancheCenter'; import {fireAndForget} from 'utils/fireAndForget'; +// Where a center switch was initiated from. Constrains the `eventOrigin` of the `centerSwitch` event +// to a known set of values so the data stays clean across call sites. +export enum CenterSwitchOrigin { + Map = 'Map', + CenterSelectorView = 'CenterSelectorView', +} + +// Any navigation object (from screen props or from useNavigation) exposes getState(). +interface NavigationWithState { + getState: () => NavigationState; +} + +/** + * Returns the name of the route directly beneath the current one in the navigator — i.e. the screen + * the current screen was presented from. Intended for use as an analytics property to distinguish + * where a screen/action was reached from. Returns null (a valid analytics value, unlike undefined) + * when the current screen is the first in its navigator (no presenter). + */ +export const getPresentedFromForAnalytics = (navigation: NavigationWithState): string | null => { + const state = navigation.getState(); + return state.routes[state.index - 1]?.name ?? null; +}; + // A thin wrapper around the PostHog client. Several PostHog v4 methods return a Promise; this wraps the // fire-and-forget calls (screen, register) so rejections are logged instead of silently swallowed. // `reloadFeatureFlags` returns its promise so callers that want the resolved flags can await it. export interface Analytics { screen(...args: Parameters): void; capture(...args: Parameters): void; + captureCenterSwitch(switchedFrom: AvalancheCenterID, switchedTo: AvalancheCenterID, origin: CenterSwitchOrigin): void; identify(...args: Parameters): void; register(...args: Parameters): void; reloadFeatureFlags(...args: Parameters): ReturnType | undefined; @@ -23,6 +49,9 @@ export const createAnalytics = (postHog: PostHog | undefined, logger: Logger): A capture: (...args) => { postHog?.capture(...args); }, + captureCenterSwitch: (switchedFrom, switchedTo, origin) => { + postHog?.capture('centerSwitch', {switchedFrom: switchedFrom, switchedTo: switchedTo, eventOrigin: origin}); + }, identify: (...args) => { postHog?.identify(...args); },