feat(mobile): bring inbox analytics to parity with desktop#2411
feat(mobile): bring inbox analytics to parity with desktop#2411Gilbert09 wants to merge 3 commits into
Conversation
Mirror desktop's inbox event names + property shapes on the mobile app so both clients are comparable in PostHog dashboards. Events wired: - `Inbox viewed` — fired once per inbox-tab focus, with priority/actionability breakdown counts (per #2295). - `Inbox report opened` / `closed` / `scrolled` — fired from the report detail screen via a new `useInboxEngagementTracker` hook, with priority + actionability snapshotted at open time. - `Inbox report action` — fired for: - Dismiss / snooze via the dismiss sheet (with `dismissal_reason` and `dismissal_note`, truncated to 1000 chars per #2287). - "Start task" on the detail screen → `create_pr`, surface `detail_pane`. - Tinder swipe-right (accept) → `create_pr`, surface `list_row`. - Tinder swipe-left (dismiss) → `dismiss`, surface `list_row`. - Expanding the Signals list on the detail screen → `expand_signal`. For #2369 (tagging discuss-launched task events with `signal_report_id`): the task detail screen registers a `signal_report_id` PostHog super-property for the duration of the screen when the task carries one, matching the desktop super-property behaviour. #2380 (`Signal source connected`) is intentionally skipped: there is no signal-source connect / data-source-setup flow in the mobile app. We do not fire any "Task created" event on mobile yet, so the desktop-side `Task created` tagging in #2369 also doesn't apply here — only the super-property half does, and that's what we wired. Architecture: analytics types and the `useAnalytics()` / `useActiveTaskAnalyticsContext()` hooks live in `apps/mobile/src/lib/analytics.ts`. Event names and property shapes are literal mirrors of `apps/code/src/shared/types/analytics.ts` — no shared package extraction since the desktop types live inside `apps/code`, not in a shared workspace. Tests: - `apps/mobile/src/lib/analytics.test.ts` — `computeReportAgeHours` and the `useActiveTaskAnalyticsContext` super-property lifecycle. - `apps/mobile/src/features/inbox/utils.test.ts` — `buildInboxViewedProperties` (priority/actionability counts, has_active_filters detection). - `apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts` — OPENED/CLOSED/SCROLLED lifecycle, scroll-once semantics, and signalAction inheritance vs. overrides. `pnpm --filter @posthog/mobile test` → 119 passed (14 new). `pnpm lint` clean. No new TypeScript errors. Generated-By: PostHog Code Task-Id: 2e652fec-af01-4476-b6cf-ab9b4de015db
|
| return () => { | ||
| viewedFiredRef.current = false; | ||
| }; | ||
| }, []), | ||
| ); | ||
| useEffect(() => { | ||
| if (isLoading) return; | ||
| if (viewedFiredRef.current) return; | ||
| viewedFiredRef.current = true; | ||
| analytics.track( | ||
| ANALYTICS_EVENTS.INBOX_VIEWED, | ||
| buildInboxViewedProperties(reports, totalCount, { | ||
| sourceProductFilter, | ||
| statusFilter, | ||
| suggestedReviewerFilter, | ||
| defaultStatusFilter: DEFAULT_STATUS_FILTER, | ||
| }), | ||
| ); | ||
| }, [ | ||
| analytics, | ||
| isLoading, | ||
| reports, | ||
| totalCount, | ||
| sourceProductFilter, | ||
| statusFilter, | ||
| suggestedReviewerFilter, | ||
| ]); | ||
|
|
||
| // ── Tinder mode data ────────────────────────────────────────────────────── | ||
| const decided = useDismissedReportsStore(decidedIds); |
There was a problem hiding this comment.
Inbox viewed silently skips every re-focus when data is cached
viewedFiredRef.current is reset to false by the useFocusEffect blur cleanup, which is the correct intent ("fire once per focus"). However, the reset only takes effect when the React useEffect re-runs, and that effect only re-runs when one of its listed dependencies changes. If the user navigates away and back while the data is still fresh (no loading, no filter change, no new reports), none of the deps have changed so the useEffect doesn't re-run — viewedFiredRef.current remains false but the event is never sent.
The fix is to expose the focus change as a React state value so useEffect actually re-runs on re-focus. For example, replace the useFocusEffect reset with a useState focus-version counter (setFocusVersion(v => v + 1) on focus) and add that counter to the useEffect deps, or move the event-firing logic entirely into a useFocusEffect callback that waits for isLoading to be false.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/mobile/src/app/(tabs)/inbox.tsx
Line: 50-79
Comment:
**`Inbox viewed` silently skips every re-focus when data is cached**
`viewedFiredRef.current` is reset to `false` by the `useFocusEffect` blur cleanup, which is the correct intent ("fire once per focus"). However, the reset only takes effect when the React `useEffect` re-runs, and that effect only re-runs when one of its listed dependencies changes. If the user navigates away and back while the data is still fresh (no loading, no filter change, no new reports), none of the deps have changed so the `useEffect` doesn't re-run — `viewedFiredRef.current` remains `false` but the event is never sent.
The fix is to expose the focus change as a React state value so `useEffect` actually re-runs on re-focus. For example, replace the `useFocusEffect` reset with a `useState` focus-version counter (`setFocusVersion(v => v + 1)` on focus) and add that counter to the `useEffect` deps, or move the event-firing logic entirely into a `useFocusEffect` callback that waits for `isLoading` to be `false`.
How can I resolve this? If you propose a fix, please make it concise.| const handleToggleSignals = useCallback(() => { | ||
| setSignalsExpanded((v) => { | ||
| const next = !v; | ||
| if (next && report) { | ||
| tracker.signalAction({ | ||
| report_id: report.id, | ||
| report_title: report.title ?? null, | ||
| report_age_hours: computeReportAgeHours(report.created_at), | ||
| action_type: "expand_signal", | ||
| surface: "detail_pane", | ||
| is_bulk: false, | ||
| bulk_size: 1, | ||
| }); | ||
| } | ||
| return next; | ||
| }); | ||
| }, [report, tracker]); |
There was a problem hiding this comment.
Side effect inside a
setState updater — React Strict Mode (development) calls state updaters twice to detect impure functions, which means tracker.signalAction(...) fires twice per toggle in development. This can cause confusion during the smoke test step that checks for one expand_signal event. Move the analytics call outside the updater.
| const handleToggleSignals = useCallback(() => { | |
| setSignalsExpanded((v) => { | |
| const next = !v; | |
| if (next && report) { | |
| tracker.signalAction({ | |
| report_id: report.id, | |
| report_title: report.title ?? null, | |
| report_age_hours: computeReportAgeHours(report.created_at), | |
| action_type: "expand_signal", | |
| surface: "detail_pane", | |
| is_bulk: false, | |
| bulk_size: 1, | |
| }); | |
| } | |
| return next; | |
| }); | |
| }, [report, tracker]); | |
| const handleToggleSignals = useCallback(() => { | |
| const next = !signalsExpanded; | |
| if (next && report) { | |
| tracker.signalAction({ | |
| report_id: report.id, | |
| report_title: report.title ?? null, | |
| report_age_hours: computeReportAgeHours(report.created_at), | |
| action_type: "expand_signal", | |
| surface: "detail_pane", | |
| is_bulk: false, | |
| bulk_size: 1, | |
| }); | |
| } | |
| setSignalsExpanded(next); | |
| }, [report, tracker, signalsExpanded]); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/mobile/src/app/inbox/[id].tsx
Line: 169-185
Comment:
Side effect inside a `setState` updater — React Strict Mode (development) calls state updaters twice to detect impure functions, which means `tracker.signalAction(...)` fires twice per toggle in development. This can cause confusion during the smoke test step that checks for one `expand_signal` event. Move the analytics call outside the updater.
```suggestion
const handleToggleSignals = useCallback(() => {
const next = !signalsExpanded;
if (next && report) {
tracker.signalAction({
report_id: report.id,
report_title: report.title ?? null,
report_age_hours: computeReportAgeHours(report.created_at),
action_type: "expand_signal",
surface: "detail_pane",
is_bulk: false,
bulk_size: 1,
});
}
setSignalsExpanded(next);
}, [report, tracker, signalsExpanded]);
```
How can I resolve this? If you propose a fix, please make it concise.Two fixes from the Greptile bot review: 1. **`Inbox viewed` was silently skipped on re-focus when data was cached** (P1). The `useFocusEffect` cleanup reset `viewedFiredRef` to `false`, but the firing `useEffect` only re-runs when its deps change — and on a re-focus with cached data, none of the deps had changed, so the event never fired again. Switched to a `focusVersion` state counter that bumps on every focus (so it appears in the effect's dep list) and changed the guard ref to remember which focus-version we last fired for, so we still only fire once per focus. 2. **`expand_signal` analytics fired inside a `setState` updater** (P2). React Strict Mode double-invokes updaters in development, which would double-fire the event. Read the next value outside the updater, fire the analytics, then call `setSignalsExpanded(next)`. No behaviour change beyond the bug fixes. `pnpm --filter @posthog/mobile lint` clean, all 119 tests still passing, no new TypeScript errors. Generated-By: PostHog Code Task-Id: 2e652fec-af01-4476-b6cf-ab9b4de015db
…ENED spikes Greptile P1 (Comments Outside Diff): `useInboxEngagementTracker`'s lifecycle `useEffect` had `report`, `rank`, `listSize`, `openMethod`, and `previousReportId` in its dep list. When the inbox list refetched in the background, any change to those values (e.g. the same report shifting rank, or the report's priority/actionability shape changing) would fire a spurious `INBOX_REPORT_CLOSED` with near-zero `time_spent_ms` followed immediately by a new `INBOX_REPORT_OPENED`. Snapshot those inputs through refs so only `reportId` gates open/close. The OPENED event still records rank / listSize / open method as-of mount, but a same-report shape change can no longer churn the lifecycle. Added a regression test asserting that rerendering with a new rank + listSize + report shape (same `id`) does not produce any additional OPENED or CLOSED events. Generated-By: PostHog Code Task-Id: 2e652fec-af01-4476-b6cf-ab9b4de015db
There was a problem hiding this comment.
The PR exceeds the automated review size ceiling (1273 lines, 14 files). It introduces new analytics infrastructure (a new analytics module) and a new engagement tracker hook, which warrants human review to validate the event naming conventions, correctness of tracking logic, and absence of data leakage.
k11kirky
left a comment
There was a problem hiding this comment.
would prefer we run a /simplify skill on these as part of the prompt <3 very verbose comments
Summary
Brings mobile-app inbox analytics up to parity with the desktop app so events fired from both clients land in the same PostHog buckets and can be funnelled together.
Mirrors the events introduced in desktop PRs #2228, #2287, #2295, and #2369.
Events wired on mobile
Inbox viewed— fired once per inbox-tab focus when reports settle. Carries the priority (priority_p{0..4}_count,priority_unknown_count) and actionability (actionability_immediately_actionable_count, etc.) breakdowns from feat(code): add priority + actionability to all inbox events #2295, plusreport_count/total_count/ready_count/source_product_filter/status_filter_count/has_active_filters/is_empty/is_gated_due_to_scale: false.Inbox report opened— fired from the/inbox/[id]detail screen on mount, withrank+list_sizesnapshotted from the list view,open_method(currently"click"by default — mobile has no Cmd/Shift modifiers),previous_report_idchained across opens via the inbox store, and thepriority+actionabilitysnapshot from feat(code): add priority + actionability to all inbox events #2295.Inbox report closed— fired when the detail screen unmounts (close_method: "deselected"), withtime_spent_ms,scrolled, and snapshotted priority/actionability.Inbox report scrolled— fired once per open on the first scroll inside the detail pane, withtime_since_open_ms.Inbox report action— fired for:DismissReportSheet(withdismissal_reasonanddismissal_notetruncated to 1000 chars per feat(inbox): capture dismissal_note in dismiss analytics event #2287). Snooze is detected the same way as desktop, via thesnoozesInsteadOfDismissflag on the reason option.create_pr, surfacedetail_pane.create_pr, surfacelist_row.dismiss, surfacelist_row.expand_signal.For #2369 (tag discuss-launched task events with
signal_report_id): the task detail screen registers asignal_report_idPostHog super-property whenever the loaded task has one, and clears it on unmount. Every subsequent event fired from that task screen carries the signal report id, matching the desktopsetActiveTaskAnalyticsContextbehaviour.Events intentionally skipped
Signal source connected— mobile has no signal-source toggle orDataSourceSetupwizard surface. There is no place inapps/mobileto fire this from without inventing a flow.Task createdtagging — mobile does not currently fire aTask createdanalytics event at all, so there is no event to add thesignal_report_idproperty to. Only the super-property half of feat(analytics): tag discuss-launched task events with signal_report_id #2369 ports cleanly to mobile, and that's what's implemented here.Implementation notes
apps/mobile/src/lib/analytics.tshousesANALYTICS_EVENTS, the typed property interfaces,useAnalytics(),useActiveTaskAnalyticsContext(), andcomputeReportAgeHours(). Event names + shapes are literal mirrors ofapps/code/src/shared/types/analytics.ts— kept in lockstep manually since the desktop types live insideapps/code(no shared workspace package to import from).useInboxEngagementTracker(mobile-adapted port of the desktop hook of the same name) owns the OPENED/CLOSED/SCROLLED lifecycle keyed on the open report.inboxStoregainedlastVisibleReportIds+previousOpenedReportIdso the detail screen can recordrank/list_size/previous_report_idon OPENED without prop-drilling.DismissReportSheetnow passes the dismissal{ reason, note }back viaonDismissedso the parent can route it through analytics — keeping the sheet stateless about which surface launched it (toolbar vs. detail).Test plan
pnpm --filter @posthog/mobile test— 119 passed (14 new acrosslib/analytics.test.ts,features/inbox/utils.test.ts,features/inbox/hooks/useInboxEngagementTracker.test.ts).pnpm --filter @posthog/mobile lint— clean.npx tsc --noEmitcount unchanged at 47 pre-existing errors).Inbox viewedin PostHog activity feed with the new breakdown counts.Inbox report openedwithrank/list_size/previous_report_id/priority/actionability. Scroll inside → confirm oneInbox report scrolled. Tap back → confirmInbox report closedwith realistictime_spent_msandscrolled: true.Inbox report actionwithaction_type: "dismiss",dismissal_reason,dismissal_note(truncated at 1000 chars if longer). Empty/whitespace-only note → nodismissal_note. Reasonalready_fixed→action_type: "snooze"and no dismissal_reason/note attached.Inbox report actionwithaction_type: "dismiss",surface: "list_row". Tinder swipe-right (accept) →Inbox report actionwithaction_type: "create_pr",surface: "list_row".Inbox report actionwithaction_type: "create_pr",surface: "detail_pane". Subsequent task screen events carry thesignal_report_idsuper-property. Navigating back to the inbox clears the super-property.