Skip to content

feat(mobile): bring inbox analytics to parity with desktop#2411

Open
Gilbert09 wants to merge 3 commits into
mainfrom
posthog-code/mobile-inbox-analytics-parity
Open

feat(mobile): bring inbox analytics to parity with desktop#2411
Gilbert09 wants to merge 3 commits into
mainfrom
posthog-code/mobile-inbox-analytics-parity

Conversation

@Gilbert09
Copy link
Copy Markdown
Member

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, plus report_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, with rank + list_size snapshotted from the list view, open_method (currently "click" by default — mobile has no Cmd/Shift modifiers), previous_report_id chained across opens via the inbox store, and the priority + actionability snapshot from feat(code): add priority + actionability to all inbox events #2295.
  • Inbox report closed — fired when the detail screen unmounts (close_method: "deselected"), with time_spent_ms, scrolled, and snapshotted priority/actionability.
  • Inbox report scrolled — fired once per open on the first scroll inside the detail pane, with time_since_open_ms.
  • Inbox report action — fired for:
    • Dismiss / snooze via DismissReportSheet (with dismissal_reason and dismissal_note truncated to 1000 chars per feat(inbox): capture dismissal_note in dismiss analytics event #2287). Snooze is detected the same way as desktop, via the snoozesInsteadOfDismiss flag on the reason option.
    • 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 (tag discuss-launched task events with signal_report_id): the task detail screen registers a signal_report_id PostHog 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 desktop setActiveTaskAnalyticsContext behaviour.

Events intentionally skipped

Implementation notes

  • New module apps/mobile/src/lib/analytics.ts houses ANALYTICS_EVENTS, the typed property interfaces, useAnalytics(), useActiveTaskAnalyticsContext(), and computeReportAgeHours(). Event names + shapes are literal mirrors of apps/code/src/shared/types/analytics.ts — kept in lockstep manually since the desktop types live inside apps/code (no shared workspace package to import from).
  • New hook useInboxEngagementTracker (mobile-adapted port of the desktop hook of the same name) owns the OPENED/CLOSED/SCROLLED lifecycle keyed on the open report.
  • inboxStore gained lastVisibleReportIds + previousOpenedReportId so the detail screen can record rank/list_size/previous_report_id on OPENED without prop-drilling.
  • DismissReportSheet now passes the dismissal { reason, note } back via onDismissed so the parent can route it through analytics — keeping the sheet stateless about which surface launched it (toolbar vs. detail).
  • No desktop code touched.

Test plan

  • pnpm --filter @posthog/mobile test — 119 passed (14 new across lib/analytics.test.ts, features/inbox/utils.test.ts, features/inbox/hooks/useInboxEngagementTracker.test.ts).
  • pnpm --filter @posthog/mobile lint — clean.
  • No new TypeScript errors (npx tsc --noEmit count unchanged at 47 pre-existing errors).
  • Smoke in dev: open inbox tab → confirm Inbox viewed in PostHog activity feed with the new breakdown counts.
  • Open a report → confirm Inbox report opened with rank/list_size/previous_report_id/priority/actionability. Scroll inside → confirm one Inbox report scrolled. Tap back → confirm Inbox report closed with realistic time_spent_ms and scrolled: true.
  • Dismiss a report via the sheet with a reason + note → confirm Inbox report action with action_type: "dismiss", dismissal_reason, dismissal_note (truncated at 1000 chars if longer). Empty/whitespace-only note → no dismissal_note. Reason already_fixedaction_type: "snooze" and no dismissal_reason/note attached.
  • Tinder swipe-left → Inbox report action with action_type: "dismiss", surface: "list_row". Tinder swipe-right (accept) → Inbox report action with action_type: "create_pr", surface: "list_row".
  • Tap "Start task" on detail → Inbox report action with action_type: "create_pr", surface: "detail_pane". Subsequent task screen events carry the signal_report_id super-property. Navigating back to the inbox clears the super-property.

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
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 28, 2026

Comments Outside Diff (1)

  1. apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts, line 798-839 (link)

    P1 Spurious CLOSED+OPENED pairs when rank/listSize/report change while a detail screen is open

    rank, listSize, and report are all included in the useEffect dependency array. If the inbox list refreshes in the background (React Query background refetch, a report is added/removed, or the order changes), every one of these changes while the user is actively reading a report will fire a cleanup INBOX_REPORT_CLOSED (with near-zero time_spent_ms) immediately followed by a new INBOX_REPORT_OPENED. This inflates both event counts and makes funnel analysis unreliable.

    The fix is to make only reportId drive the open/close lifecycle. Snapshot rank, listSize, openMethod, and previousReportId once at the moment the report opens by feeding them through refs and reading the ref values at mount rather than keeping them as live deps. Similarly, report can be read from a ref inside the effect body (since only its initial value matters for the OPENED event) — only reportId should gate the re-run.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts
    Line: 798-839
    
    Comment:
    **Spurious CLOSED+OPENED pairs when rank/listSize/report change while a detail screen is open**
    
    `rank`, `listSize`, and `report` are all included in the `useEffect` dependency array. If the inbox list refreshes in the background (React Query background refetch, a report is added/removed, or the order changes), every one of these changes while the user is actively reading a report will fire a cleanup `INBOX_REPORT_CLOSED` (with near-zero `time_spent_ms`) immediately followed by a new `INBOX_REPORT_OPENED`. This inflates both event counts and makes funnel analysis unreliable.
    
    The fix is to make only `reportId` drive the open/close lifecycle. Snapshot `rank`, `listSize`, `openMethod`, and `previousReportId` once at the moment the report opens by feeding them through refs and reading the ref values at mount rather than keeping them as live deps. Similarly, `report` can be read from a ref inside the effect body (since only its initial value matters for the OPENED event) — only `reportId` should gate the re-run.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts:798-839
**Spurious CLOSED+OPENED pairs when rank/listSize/report change while a detail screen is open**

`rank`, `listSize`, and `report` are all included in the `useEffect` dependency array. If the inbox list refreshes in the background (React Query background refetch, a report is added/removed, or the order changes), every one of these changes while the user is actively reading a report will fire a cleanup `INBOX_REPORT_CLOSED` (with near-zero `time_spent_ms`) immediately followed by a new `INBOX_REPORT_OPENED`. This inflates both event counts and makes funnel analysis unreliable.

The fix is to make only `reportId` drive the open/close lifecycle. Snapshot `rank`, `listSize`, `openMethod`, and `previousReportId` once at the moment the report opens by feeding them through refs and reading the ref values at mount rather than keeping them as live deps. Similarly, `report` can be read from a ref inside the effect body (since only its initial value matters for the OPENED event) — only `reportId` should gate the re-run.

### Issue 2 of 3
apps/mobile/src/app/(tabs)/inbox.tsx:50-79
**`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`.

### Issue 3 of 3
apps/mobile/src/app/inbox/[id].tsx:169-185
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]);
```

Reviews (1): Last reviewed commit: "feat(mobile): bring inbox analytics to p..." | Re-trigger Greptile

Comment thread apps/mobile/src/app/(tabs)/inbox.tsx Outdated
Comment on lines 50 to 79
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment thread apps/mobile/src/app/inbox/[id].tsx Outdated
Comment on lines +169 to +185
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]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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.

Gilbert09 added 2 commits May 28, 2026 13:21
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
@Gilbert09 Gilbert09 added the Stamphog This will request an autostamp by stamphog on small changes label May 28, 2026
Copy link
Copy Markdown

@stamphog stamphog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@stamphog stamphog Bot removed the Stamphog This will request an autostamp by stamphog on small changes label May 28, 2026
@Gilbert09 Gilbert09 requested a review from a team May 28, 2026 15:09
Copy link
Copy Markdown
Contributor

@k11kirky k11kirky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would prefer we run a /simplify skill on these as part of the prompt <3 very verbose comments

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants