Skip to content

Commit bf90d93

Browse files
authored
feat(mobile): bring inbox analytics to parity with desktop (#2411)
1 parent 843e0c4 commit bf90d93

14 files changed

Lines changed: 1254 additions & 19 deletions

File tree

apps/mobile/src/app/(tabs)/inbox.tsx

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { useRouter } from "expo-router";
2-
import { useCallback, useEffect, useMemo, useState } from "react";
1+
import { useFocusEffect, useRouter } from "expo-router";
2+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33
import { View } from "react-native";
44
import { useSafeAreaInsets } from "react-native-safe-area-context";
55
import { FilterSheet } from "@/features/inbox/components/FilterSheet";
@@ -13,29 +13,92 @@ import {
1313
decidedIds,
1414
useDismissedReportsStore,
1515
} from "@/features/inbox/stores/dismissedReportsStore";
16-
import { useInboxFilterStore } from "@/features/inbox/stores/inboxFilterStore";
16+
import {
17+
DEFAULT_STATUS_FILTER,
18+
useInboxFilterStore,
19+
} from "@/features/inbox/stores/inboxFilterStore";
1720
import { useInboxStore } from "@/features/inbox/stores/inboxStore";
1821
import type { SignalReport } from "@/features/inbox/types";
22+
import { buildInboxViewedProperties } from "@/features/inbox/utils";
1923
import { useIntegrations } from "@/features/tasks/hooks/useIntegrations";
24+
import { ANALYTICS_EVENTS, useAnalytics } from "@/lib/analytics";
2025

2126
type InboxViewMode = "list" | "tinder";
2227

2328
export default function InboxScreen() {
2429
const insets = useSafeAreaInsets();
2530
const router = useRouter();
26-
const { reports, isFetching, isLoading, error } = useInboxReports();
31+
const { reports, totalCount, isFetching, isLoading, error } =
32+
useInboxReports();
2733
const [filterOpen, setFilterOpen] = useState(false);
2834
const [reviewerOpen, setReviewerOpen] = useState(false);
2935
const [viewMode, setViewMode] = useState<InboxViewMode>("list");
3036
const reviewerFilterCount = useInboxFilterStore(
3137
(s) => s.suggestedReviewerFilter.length,
3238
);
39+
const sourceProductFilter = useInboxFilterStore((s) => s.sourceProductFilter);
40+
const statusFilter = useInboxFilterStore((s) => s.statusFilter);
41+
const suggestedReviewerFilter = useInboxFilterStore(
42+
(s) => s.suggestedReviewerFilter,
43+
);
44+
45+
const analytics = useAnalytics();
46+
// Fire INBOX_VIEWED once per focus when the report list has settled. We
47+
// bump a focus counter on every focus so the useEffect re-runs even when
48+
// the data is already cached (no loading/filter/list change to trigger it
49+
// on its own), then guard against double-fires within the same focus via
50+
// a ref keyed on the focus-version we last fired for.
51+
const [focusVersion, setFocusVersion] = useState(0);
52+
useFocusEffect(
53+
useCallback(() => {
54+
setFocusVersion((v) => v + 1);
55+
}, []),
56+
);
57+
const viewedFiredForFocusRef = useRef<number | null>(null);
58+
useEffect(() => {
59+
if (focusVersion === 0) return;
60+
if (isLoading) return;
61+
if (viewedFiredForFocusRef.current === focusVersion) return;
62+
viewedFiredForFocusRef.current = focusVersion;
63+
analytics.track(
64+
ANALYTICS_EVENTS.INBOX_VIEWED,
65+
buildInboxViewedProperties(reports, totalCount, {
66+
sourceProductFilter,
67+
statusFilter,
68+
suggestedReviewerFilter,
69+
defaultStatusFilter: DEFAULT_STATUS_FILTER,
70+
}),
71+
);
72+
}, [
73+
analytics,
74+
focusVersion,
75+
isLoading,
76+
reports,
77+
totalCount,
78+
sourceProductFilter,
79+
statusFilter,
80+
suggestedReviewerFilter,
81+
]);
3382

3483
// ── Tinder mode data ──────────────────────────────────────────────────────
3584
const decided = useDismissedReportsStore(decidedIds);
3685
const setCurrentIndex = useInboxStore((s) => s.setCurrentIndex);
86+
const setLastVisibleReportIds = useInboxStore(
87+
(s) => s.setLastVisibleReportIds,
88+
);
3789
const { repositoryOptions } = useIntegrations();
3890

91+
// Snapshot the visible-list IDs into the store so the detail screen can
92+
// record rank/list_size on OPENED. Only the list view exposes a rank — the
93+
// tinder card stack swaps cards in place.
94+
useEffect(() => {
95+
if (viewMode === "list") {
96+
setLastVisibleReportIds(reports.map((r) => r.id));
97+
} else {
98+
setLastVisibleReportIds([]);
99+
}
100+
}, [viewMode, reports, setLastVisibleReportIds]);
101+
39102
// Same data as the list view, excluding already-decided reports.
40103
const tinderReports = useMemo(
41104
() => reports.filter((r) => !decided.includes(r.id)),

apps/mobile/src/app/inbox/[...id].tsx

Lines changed: 116 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,32 @@ import {
1414
} from "phosphor-react-native";
1515
import { usePostHog } from "posthog-react-native";
1616
import { useCallback, useEffect, useMemo, useState } from "react";
17-
import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
17+
import {
18+
ActivityIndicator,
19+
type NativeScrollEvent,
20+
type NativeSyntheticEvent,
21+
Pressable,
22+
ScrollView,
23+
View,
24+
} from "react-native";
1825
import { useSafeAreaInsets } from "react-native-safe-area-context";
1926
import { MarkdownText } from "@/features/chat/components/MarkdownText";
2027
import { getReportRepository } from "@/features/inbox/api";
2128
import { DiscussReportSheet } from "@/features/inbox/components/DiscussReportSheet";
22-
import { DismissReportSheet } from "@/features/inbox/components/DismissReportSheet";
29+
import {
30+
type DismissReportResult,
31+
DismissReportSheet,
32+
} from "@/features/inbox/components/DismissReportSheet";
2333
import { SignalCard } from "@/features/inbox/components/SignalCard";
2434
import { SuggestedReviewers } from "@/features/inbox/components/SuggestedReviewers";
35+
import { DISMISSAL_REASON_OPTIONS } from "@/features/inbox/constants";
36+
import { useInboxEngagementTracker } from "@/features/inbox/hooks/useInboxEngagementTracker";
2537
import {
2638
useInboxReport,
2739
useInboxReportArtefacts,
2840
useInboxReportSignals,
2941
} from "@/features/inbox/hooks/useInboxReports";
42+
import { useInboxStore } from "@/features/inbox/stores/inboxStore";
3043
import type {
3144
ActionabilityJudgmentContent,
3245
SignalFindingContent,
@@ -35,6 +48,7 @@ import type {
3548
SuggestedReviewer,
3649
} from "@/features/inbox/types";
3750
import { inboxStatusLabel } from "@/features/inbox/utils";
51+
import { computeReportAgeHours, useAnalytics } from "@/lib/analytics";
3852
import { useThemeColors } from "@/lib/theme";
3953

4054
const statusColorMap: Record<string, { bg: string; text: string }> = {
@@ -128,6 +142,59 @@ export default function ReportDetailScreen() {
128142
const artefactsQuery = useInboxReportArtefacts(reportId ?? null);
129143
const signalsQuery = useInboxReportSignals(reportId ?? null);
130144

145+
// ── Engagement analytics ────────────────────────────────────────────────
146+
const analytics = useAnalytics();
147+
const lastVisibleReportIds = useInboxStore((s) => s.lastVisibleReportIds);
148+
const previousOpenedReportId = useInboxStore((s) => s.previousOpenedReportId);
149+
const setPreviousOpenedReportId = useInboxStore(
150+
(s) => s.setPreviousOpenedReportId,
151+
);
152+
const rank = useMemo(() => {
153+
if (!reportId) return -1;
154+
const idx = lastVisibleReportIds.indexOf(reportId);
155+
return idx;
156+
}, [reportId, lastVisibleReportIds]);
157+
const listSize = lastVisibleReportIds.length;
158+
const tracker = useInboxEngagementTracker({
159+
analytics,
160+
report: report ?? null,
161+
rank,
162+
listSize,
163+
openMethod: "click",
164+
previousReportId: previousOpenedReportId,
165+
});
166+
// Remember this report as the "previous" once it's been opened so the next
167+
// OPENED event can chain to it.
168+
useEffect(() => {
169+
if (!reportId) return;
170+
setPreviousOpenedReportId(reportId);
171+
}, [reportId, setPreviousOpenedReportId]);
172+
173+
const handleScroll = useCallback(
174+
(_event: NativeSyntheticEvent<NativeScrollEvent>) => {
175+
tracker.signalScroll();
176+
},
177+
[tracker],
178+
);
179+
180+
const handleToggleSignals = useCallback(() => {
181+
// Fire analytics outside the state updater — Strict Mode double-invokes
182+
// updaters in development, which would double-fire the event.
183+
const next = !signalsExpanded;
184+
if (next && report) {
185+
tracker.signalAction({
186+
report_id: report.id,
187+
report_title: report.title ?? null,
188+
report_age_hours: computeReportAgeHours(report.created_at),
189+
action_type: "expand_signal",
190+
surface: "detail_pane",
191+
is_bulk: false,
192+
bulk_size: 1,
193+
});
194+
}
195+
setSignalsExpanded(next);
196+
}, [report, tracker, signalsExpanded]);
197+
131198
useEffect(() => {
132199
if (!reportId) return;
133200
let cancelled = false;
@@ -187,6 +254,15 @@ export default function ReportDetailScreen() {
187254
const handleStartTask = useCallback(() => {
188255
if (!report) return;
189256
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
257+
tracker.signalAction({
258+
report_id: report.id,
259+
report_title: report.title ?? null,
260+
report_age_hours: computeReportAgeHours(report.created_at),
261+
action_type: "create_pr",
262+
surface: "detail_pane",
263+
is_bulk: false,
264+
bulk_size: 1,
265+
});
190266
const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`;
191267
router.push({
192268
pathname: "/task",
@@ -196,12 +272,41 @@ export default function ReportDetailScreen() {
196272
signalReport: report.id,
197273
},
198274
});
199-
}, [report, router, reportRepo]);
200-
201-
const handleDismissed = useCallback(() => {
202-
setDismissOpen(false);
203-
if (router.canGoBack()) router.back();
204-
}, [router]);
275+
}, [report, router, reportRepo, tracker]);
276+
277+
const handleDismissed = useCallback(
278+
(result: DismissReportResult) => {
279+
setDismissOpen(false);
280+
if (report) {
281+
const reasonOption = DISMISSAL_REASON_OPTIONS.find(
282+
(o) => o.value === result.reason,
283+
);
284+
const isSnooze =
285+
reasonOption !== undefined &&
286+
"snoozesInsteadOfDismiss" in reasonOption &&
287+
reasonOption.snoozesInsteadOfDismiss === true;
288+
tracker.signalAction({
289+
report_id: report.id,
290+
report_title: report.title ?? null,
291+
report_age_hours: computeReportAgeHours(report.created_at),
292+
action_type: isSnooze ? "snooze" : "dismiss",
293+
surface: "detail_pane",
294+
is_bulk: false,
295+
bulk_size: 1,
296+
...(isSnooze
297+
? {}
298+
: {
299+
dismissal_reason: result.reason,
300+
...(result.note
301+
? { dismissal_note: result.note.slice(0, 1000) }
302+
: {}),
303+
}),
304+
});
305+
}
306+
if (router.canGoBack()) router.back();
307+
},
308+
[router, report, tracker],
309+
);
205310

206311
const handleDiscussSubmit = useCallback(
207312
({ prompt, question }: { prompt: string; question: string }) => {
@@ -293,6 +398,8 @@ export default function ReportDetailScreen() {
293398
paddingTop: 16,
294399
paddingBottom: insets.bottom + 100,
295400
}}
401+
onScroll={handleScroll}
402+
scrollEventThrottle={250}
296403
>
297404
{/* Badges row */}
298405
<View className="mb-3 flex-row flex-wrap items-center gap-1.5">
@@ -370,7 +477,7 @@ export default function ReportDetailScreen() {
370477
{signals.length > 0 && (
371478
<View className="mb-4">
372479
<Pressable
373-
onPress={() => setSignalsExpanded((v) => !v)}
480+
onPress={handleToggleSignals}
374481
hitSlop={6}
375482
accessibilityRole="button"
376483
accessibilityState={{ expanded: signalsExpanded }}

apps/mobile/src/app/task/[id].tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { useTaskStore } from "@/features/tasks/stores/taskStore";
4141
import type { Task } from "@/features/tasks/types";
4242
import { getSessionActivityPhase } from "@/features/tasks/utils/sessionActivity";
4343
import { useScreenInsets } from "@/hooks/useScreenInsets";
44+
import { useActiveTaskAnalyticsContext } from "@/lib/analytics";
4445
import { logger } from "@/lib/logger";
4546
import { useThemeColors } from "@/lib/theme";
4647

@@ -94,6 +95,11 @@ export default function TaskDetailScreen() {
9495
};
9596
}, [taskId, setFocusedTaskId]);
9697

98+
// Tag every PostHog event fired while this task is open with the originating
99+
// inbox report id, so a discuss-launched run can be filtered down in PostHog.
100+
// Cleared when the screen unmounts. Matches the desktop super-property.
101+
useActiveTaskAnalyticsContext(task?.signal_report ?? null);
102+
97103
const session = taskId ? getSessionForTask(taskId) : undefined;
98104

99105
// Optimistic echo set by the new-task screen (or the terminal-resume path

apps/mobile/src/features/inbox/components/DismissReportSheet.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,23 @@ import {
2020
} from "../constants";
2121
import { useDismissReport } from "../hooks/useInboxReports";
2222

23+
export interface DismissReportResult {
24+
reason: DismissalReasonOptionValue;
25+
/** Trimmed note text the user provided, if any. Empty/whitespace-only notes become null. */
26+
note: string | null;
27+
}
28+
2329
interface DismissReportSheetProps {
2430
visible: boolean;
2531
reportId: string;
2632
reportTitle: string;
2733
onClose: () => void;
28-
onDismissed: () => void;
34+
/**
35+
* Fires after the API confirms the dismissal. The result is passed back so
36+
* callers can route the reason/note through their own analytics — keeping
37+
* this sheet stateless about the surface it was launched from.
38+
*/
39+
onDismissed: (result: DismissReportResult) => void;
2940
}
3041

3142
export function DismissReportSheet({
@@ -53,10 +64,14 @@ export function DismissReportSheet({
5364
const handleConfirm = async () => {
5465
if (!reason || dismiss.isPending) return;
5566
setError(null);
67+
const trimmedNote = note.trim();
5668
try {
57-
await dismiss.mutateAsync({ reason, note: note.trim() || undefined });
69+
await dismiss.mutateAsync({
70+
reason,
71+
note: trimmedNote || undefined,
72+
});
5873
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
59-
onDismissed();
74+
onDismissed({ reason, note: trimmedNote || null });
6075
} catch (err) {
6176
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
6277
setError(

0 commit comments

Comments
 (0)