diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index 7279820b7a..0c892acafc 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -1,5 +1,5 @@ -import { useRouter } from "expo-router"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useFocusEffect, useRouter } from "expo-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FilterSheet } from "@/features/inbox/components/FilterSheet"; @@ -13,29 +13,92 @@ import { decidedIds, useDismissedReportsStore, } from "@/features/inbox/stores/dismissedReportsStore"; -import { useInboxFilterStore } from "@/features/inbox/stores/inboxFilterStore"; +import { + DEFAULT_STATUS_FILTER, + useInboxFilterStore, +} from "@/features/inbox/stores/inboxFilterStore"; import { useInboxStore } from "@/features/inbox/stores/inboxStore"; import type { SignalReport } from "@/features/inbox/types"; +import { buildInboxViewedProperties } from "@/features/inbox/utils"; import { useIntegrations } from "@/features/tasks/hooks/useIntegrations"; +import { ANALYTICS_EVENTS, useAnalytics } from "@/lib/analytics"; type InboxViewMode = "list" | "tinder"; export default function InboxScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); - const { reports, isFetching, isLoading, error } = useInboxReports(); + const { reports, totalCount, isFetching, isLoading, error } = + useInboxReports(); const [filterOpen, setFilterOpen] = useState(false); const [reviewerOpen, setReviewerOpen] = useState(false); const [viewMode, setViewMode] = useState("list"); const reviewerFilterCount = useInboxFilterStore( (s) => s.suggestedReviewerFilter.length, ); + const sourceProductFilter = useInboxFilterStore((s) => s.sourceProductFilter); + const statusFilter = useInboxFilterStore((s) => s.statusFilter); + const suggestedReviewerFilter = useInboxFilterStore( + (s) => s.suggestedReviewerFilter, + ); + + const analytics = useAnalytics(); + // Fire INBOX_VIEWED once per focus when the report list has settled. We + // bump a focus counter on every focus so the useEffect re-runs even when + // the data is already cached (no loading/filter/list change to trigger it + // on its own), then guard against double-fires within the same focus via + // a ref keyed on the focus-version we last fired for. + const [focusVersion, setFocusVersion] = useState(0); + useFocusEffect( + useCallback(() => { + setFocusVersion((v) => v + 1); + }, []), + ); + const viewedFiredForFocusRef = useRef(null); + useEffect(() => { + if (focusVersion === 0) return; + if (isLoading) return; + if (viewedFiredForFocusRef.current === focusVersion) return; + viewedFiredForFocusRef.current = focusVersion; + analytics.track( + ANALYTICS_EVENTS.INBOX_VIEWED, + buildInboxViewedProperties(reports, totalCount, { + sourceProductFilter, + statusFilter, + suggestedReviewerFilter, + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }), + ); + }, [ + analytics, + focusVersion, + isLoading, + reports, + totalCount, + sourceProductFilter, + statusFilter, + suggestedReviewerFilter, + ]); // ── Tinder mode data ────────────────────────────────────────────────────── const decided = useDismissedReportsStore(decidedIds); const setCurrentIndex = useInboxStore((s) => s.setCurrentIndex); + const setLastVisibleReportIds = useInboxStore( + (s) => s.setLastVisibleReportIds, + ); const { repositoryOptions } = useIntegrations(); + // Snapshot the visible-list IDs into the store so the detail screen can + // record rank/list_size on OPENED. Only the list view exposes a rank — the + // tinder card stack swaps cards in place. + useEffect(() => { + if (viewMode === "list") { + setLastVisibleReportIds(reports.map((r) => r.id)); + } else { + setLastVisibleReportIds([]); + } + }, [viewMode, reports, setLastVisibleReportIds]); + // Same data as the list view, excluding already-decided reports. const tinderReports = useMemo( () => reports.filter((r) => !decided.includes(r.id)), diff --git a/apps/mobile/src/app/inbox/[id].tsx b/apps/mobile/src/app/inbox/[id].tsx index 0a119d0eb0..8d8c243c5a 100644 --- a/apps/mobile/src/app/inbox/[id].tsx +++ b/apps/mobile/src/app/inbox/[id].tsx @@ -12,18 +12,31 @@ import { Warning, } from "phosphor-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; +import { + ActivityIndicator, + type NativeScrollEvent, + type NativeSyntheticEvent, + Pressable, + ScrollView, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { getReportRepository } from "@/features/inbox/api"; -import { DismissReportSheet } from "@/features/inbox/components/DismissReportSheet"; +import { + type DismissReportResult, + DismissReportSheet, +} from "@/features/inbox/components/DismissReportSheet"; import { SignalCard } from "@/features/inbox/components/SignalCard"; import { SuggestedReviewers } from "@/features/inbox/components/SuggestedReviewers"; +import { DISMISSAL_REASON_OPTIONS } from "@/features/inbox/constants"; +import { useInboxEngagementTracker } from "@/features/inbox/hooks/useInboxEngagementTracker"; import { useInboxReport, useInboxReportArtefacts, useInboxReportSignals, } from "@/features/inbox/hooks/useInboxReports"; +import { useInboxStore } from "@/features/inbox/stores/inboxStore"; import type { ActionabilityJudgmentContent, SignalFindingContent, @@ -32,6 +45,7 @@ import type { SuggestedReviewer, } from "@/features/inbox/types"; import { inboxStatusLabel } from "@/features/inbox/utils"; +import { computeReportAgeHours, useAnalytics } from "@/lib/analytics"; import { useThemeColors } from "@/lib/theme"; const statusColorMap: Record = { @@ -117,6 +131,59 @@ export default function ReportDetailScreen() { const artefactsQuery = useInboxReportArtefacts(reportId ?? null); const signalsQuery = useInboxReportSignals(reportId ?? null); + // ── Engagement analytics ──────────────────────────────────────────────── + const analytics = useAnalytics(); + const lastVisibleReportIds = useInboxStore((s) => s.lastVisibleReportIds); + const previousOpenedReportId = useInboxStore((s) => s.previousOpenedReportId); + const setPreviousOpenedReportId = useInboxStore( + (s) => s.setPreviousOpenedReportId, + ); + const rank = useMemo(() => { + if (!reportId) return -1; + const idx = lastVisibleReportIds.indexOf(reportId); + return idx; + }, [reportId, lastVisibleReportIds]); + const listSize = lastVisibleReportIds.length; + const tracker = useInboxEngagementTracker({ + analytics, + report: report ?? null, + rank, + listSize, + openMethod: "click", + previousReportId: previousOpenedReportId, + }); + // Remember this report as the "previous" once it's been opened so the next + // OPENED event can chain to it. + useEffect(() => { + if (!reportId) return; + setPreviousOpenedReportId(reportId); + }, [reportId, setPreviousOpenedReportId]); + + const handleScroll = useCallback( + (_event: NativeSyntheticEvent) => { + tracker.signalScroll(); + }, + [tracker], + ); + + const handleToggleSignals = useCallback(() => { + // Fire analytics outside the state updater — Strict Mode double-invokes + // updaters in development, which would double-fire the event. + 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]); + useEffect(() => { if (!reportId) return; let cancelled = false; @@ -176,6 +243,15 @@ export default function ReportDetailScreen() { const handleStartTask = useCallback(() => { if (!report) return; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + tracker.signalAction({ + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + action_type: "create_pr", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + }); const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; router.push({ pathname: "/task", @@ -185,12 +261,41 @@ export default function ReportDetailScreen() { signalReport: report.id, }, }); - }, [report, router, reportRepo]); - - const handleDismissed = useCallback(() => { - setDismissOpen(false); - if (router.canGoBack()) router.back(); - }, [router]); + }, [report, router, reportRepo, tracker]); + + const handleDismissed = useCallback( + (result: DismissReportResult) => { + setDismissOpen(false); + if (report) { + const reasonOption = DISMISSAL_REASON_OPTIONS.find( + (o) => o.value === result.reason, + ); + const isSnooze = + reasonOption !== undefined && + "snoozesInsteadOfDismiss" in reasonOption && + reasonOption.snoozesInsteadOfDismiss === true; + tracker.signalAction({ + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + action_type: isSnooze ? "snooze" : "dismiss", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + ...(isSnooze + ? {} + : { + dismissal_reason: result.reason, + ...(result.note + ? { dismissal_note: result.note.slice(0, 1000) } + : {}), + }), + }); + } + if (router.canGoBack()) router.back(); + }, + [router, report, tracker], + ); if (error) { return ( @@ -254,6 +359,8 @@ export default function ReportDetailScreen() { paddingTop: 16, paddingBottom: insets.bottom + 100, }} + onScroll={handleScroll} + scrollEventThrottle={250} > {/* Badges row */} @@ -331,7 +438,7 @@ export default function ReportDetailScreen() { {signals.length > 0 && ( setSignalsExpanded((v) => !v)} + onPress={handleToggleSignals} hitSlop={6} accessibilityRole="button" accessibilityState={{ expanded: signalsExpanded }} diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 2d0c494ddb..a74e9ff1eb 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -36,6 +36,7 @@ import { useTaskStore } from "@/features/tasks/stores/taskStore"; import type { Task } from "@/features/tasks/types"; import { getSessionActivityPhase } from "@/features/tasks/utils/sessionActivity"; import { useScreenInsets } from "@/hooks/useScreenInsets"; +import { useActiveTaskAnalyticsContext } from "@/lib/analytics"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; @@ -89,6 +90,11 @@ export default function TaskDetailScreen() { }; }, [taskId, setFocusedTaskId]); + // Tag every PostHog event fired while this task is open with the originating + // inbox report id, so a discuss-launched run can be filtered down in PostHog. + // Cleared when the screen unmounts. Matches the desktop super-property. + useActiveTaskAnalyticsContext(task?.signal_report ?? null); + const session = taskId ? getSessionForTask(taskId) : undefined; // Per-task composer pill values. Persisted in taskStore so reopening the diff --git a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx index ade712ab78..65fc913399 100644 --- a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx +++ b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx @@ -20,12 +20,23 @@ import { } from "../constants"; import { useDismissReport } from "../hooks/useInboxReports"; +export interface DismissReportResult { + reason: DismissalReasonOptionValue; + /** Trimmed note text the user provided, if any. Empty/whitespace-only notes become null. */ + note: string | null; +} + interface DismissReportSheetProps { visible: boolean; reportId: string; reportTitle: string; onClose: () => void; - onDismissed: () => void; + /** + * Fires after the API confirms the dismissal. The result is passed back so + * callers can route the reason/note through their own analytics — keeping + * this sheet stateless about the surface it was launched from. + */ + onDismissed: (result: DismissReportResult) => void; } export function DismissReportSheet({ @@ -53,10 +64,14 @@ export function DismissReportSheet({ const handleConfirm = async () => { if (!reason || dismiss.isPending) return; setError(null); + const trimmedNote = note.trim(); try { - await dismiss.mutateAsync({ reason, note: note.trim() || undefined }); + await dismiss.mutateAsync({ + reason, + note: trimmedNote || undefined, + }); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - onDismissed(); + onDismissed({ reason, note: trimmedNote || null }); } catch (err) { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); setError( diff --git a/apps/mobile/src/features/inbox/components/TinderView.tsx b/apps/mobile/src/features/inbox/components/TinderView.tsx index 7eb2b5fd9d..6c0430d883 100644 --- a/apps/mobile/src/features/inbox/components/TinderView.tsx +++ b/apps/mobile/src/features/inbox/components/TinderView.tsx @@ -21,6 +21,11 @@ import type { CreateTaskOptions, RepositoryOption, } from "@/features/tasks/types"; +import { + ANALYTICS_EVENTS, + computeReportAgeHours, + useAnalytics, +} from "@/lib/analytics"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; import { getReportRepository } from "../api"; @@ -137,6 +142,34 @@ export function TinderView({ const dismissReport = useDismissedReportsStore((s) => s.dismissReport); const acceptReport = useDismissedReportsStore((s) => s.acceptReport); + const analytics = useAnalytics(); + + const trackReportAction = useCallback( + ( + report: SignalReport, + actionType: "dismiss" | "create_pr", + position: number, + total: number, + ) => { + analytics.track(ANALYTICS_EVENTS.INBOX_REPORT_ACTION, { + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + priority: report.priority ?? null, + actionability: report.actionability ?? null, + action_type: actionType, + // Tinder cards stack like a list of rows the user is acting on + // without opening a detail view — closest desktop analogue. + surface: "list_row", + is_bulk: false, + bulk_size: 1, + rank: position, + list_size: total, + }); + }, + [analytics], + ); + // Local state const [expandedReport, setExpandedReport] = useState( null, @@ -161,15 +194,22 @@ export function TinderView({ toastTimer.current = setTimeout(() => setToast(null), 10_000); }, []); + const reportsRef = useRef(reports); + reportsRef.current = reports; + const handleDismiss = useCallback( (reportId: string) => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const visible = reportsRef.current; + const idx = visible.findIndex((r) => r.id === reportId); + const target = idx >= 0 ? visible[idx] : null; + if (target) trackReportAction(target, "dismiss", idx, visible.length); dismissReport(reportId); // Don't advanceCard() — the parent filters dismissed IDs from the // reports array, so removing the report shifts the next one into // the current index position automatically. }, - [dismissReport], + [dismissReport, trackReportAction], ); const handleAccept = useCallback( @@ -177,6 +217,11 @@ export function TinderView({ setCreating(true); setError(null); showToastPending(report.title ?? "Untitled report"); + // Snapshot rank/list_size before the swipe completes — accepting filters + // the report out of the visible deck. + const visibleBefore = reportsRef.current; + const acceptedRank = visibleBefore.findIndex((r) => r.id === report.id); + const acceptedListSize = visibleBefore.length; try { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); @@ -213,6 +258,7 @@ export function TinderView({ }); acceptReport(report.id); + trackReportAction(report, "create_pr", acceptedRank, acceptedListSize); showToastDone(task.id, report.title ?? "Untitled report"); } catch (e) { const message = @@ -224,7 +270,13 @@ export function TinderView({ setCreating(false); } }, - [repositoryOptions, showToastPending, showToastDone, acceptReport], + [ + repositoryOptions, + showToastPending, + showToastDone, + acceptReport, + trackReportAction, + ], ); const currentReport = diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts new file mode 100644 index 0000000000..2e970def6b --- /dev/null +++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts @@ -0,0 +1,273 @@ +import { createElement } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// posthog-react-native pulls in real react-native at import time, which vitest +// can't parse. The hook only uses `Analytics.track`, which is passed in by the +// caller — so mocking the module to a no-op keeps the import graph quiet. +vi.mock("posthog-react-native", () => ({ + usePostHog: () => null, +})); + +import { ANALYTICS_EVENTS, type Analytics } from "@/lib/analytics"; +import type { SignalReport } from "../types"; +import { + type InboxEngagementTracker, + type UseInboxEngagementTrackerOptions, + useInboxEngagementTracker, +} from "./useInboxEngagementTracker"; + +function makeReport(overrides: Partial = {}): SignalReport { + return { + id: "r1", + title: "Report 1", + summary: null, + status: "ready", + total_weight: 0, + signal_count: 0, + created_at: "2026-01-01T12:00:00Z", + updated_at: "2026-01-01T12:00:00Z", + artefact_count: 0, + priority: "P1", + actionability: "immediately_actionable", + source_products: ["error_tracking"], + ...overrides, + }; +} + +function renderTracker(initial: UseInboxEngagementTrackerOptions) { + const trackerRef: { current: InboxEngagementTracker | null } = { + current: null, + }; + let currentOptions = initial; + function Wrapper() { + trackerRef.current = useInboxEngagementTracker(currentOptions); + return null; + } + let renderer: ReactTestRenderer | null = null; + act(() => { + renderer = create(createElement(Wrapper)); + }); + return { + tracker: () => { + if (!trackerRef.current) throw new Error("tracker not initialised"); + return trackerRef.current; + }, + rerender: (next: UseInboxEngagementTrackerOptions) => { + currentOptions = next; + act(() => { + renderer?.update(createElement(Wrapper)); + }); + }, + unmount: () => { + act(() => { + renderer?.unmount(); + }); + }, + }; +} + +describe("useInboxEngagementTracker", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T13:00:00Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("fires OPENED on mount with the right snapshot of report fields", () => { + const track = vi.fn(); + const analytics: Analytics = { track }; + const report = makeReport(); + renderTracker({ + analytics, + report, + rank: 2, + listSize: 5, + openMethod: "click", + previousReportId: "prev-1", + }); + expect(track).toHaveBeenCalledWith( + ANALYTICS_EVENTS.INBOX_REPORT_OPENED, + expect.objectContaining({ + report_id: "r1", + report_title: "Report 1", + report_age_hours: 1, + status: "ready", + priority: "P1", + actionability: "immediately_actionable", + source_products: ["error_tracking"], + rank: 2, + list_size: 5, + open_method: "click", + previous_report_id: "prev-1", + }), + ); + }); + + it("fires CLOSED on unmount with time_spent, scrolled, close_method", () => { + const track = vi.fn(); + const report = makeReport(); + const hook = renderTracker({ + analytics: { track }, + report, + rank: 0, + listSize: 1, + openMethod: "click", + previousReportId: null, + }); + act(() => { + hook.tracker().signalScroll(); + }); + vi.advanceTimersByTime(2500); + hook.unmount(); + const closeCall = track.mock.calls.find( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_CLOSED, + ); + expect(closeCall).toBeDefined(); + expect(closeCall?.[1]).toMatchObject({ + report_id: "r1", + scrolled: true, + close_method: "deselected", + priority: "P1", + actionability: "immediately_actionable", + }); + expect((closeCall?.[1] as { time_spent_ms: number }).time_spent_ms).toBe( + 2500, + ); + }); + + it("fires SCROLLED at most once per open", () => { + const track = vi.fn(); + const hook = renderTracker({ + analytics: { track }, + report: makeReport(), + rank: 0, + listSize: 1, + openMethod: "click", + previousReportId: null, + }); + act(() => { + hook.tracker().signalScroll(); + hook.tracker().signalScroll(); + hook.tracker().signalScroll(); + }); + const scrollCalls = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED, + ); + expect(scrollCalls).toHaveLength(1); + }); + + it("signalAction inherits rank/list_size/priority/actionability from the current open", () => { + const track = vi.fn(); + const report = makeReport({ priority: "P0" }); + const hook = renderTracker({ + analytics: { track }, + report, + rank: 3, + listSize: 7, + openMethod: "click", + previousReportId: null, + }); + act(() => { + hook.tracker().signalAction({ + report_id: "r1", + report_title: "Report 1", + report_age_hours: 1, + action_type: "create_pr", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + }); + }); + const actionCall = track.mock.calls.find( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_ACTION, + ); + expect(actionCall?.[1]).toMatchObject({ + rank: 3, + list_size: 7, + priority: "P0", + actionability: "immediately_actionable", + }); + }); + + it("does not re-fire OPENED/CLOSED when rank/listSize/report change while the same report stays open", () => { + // Regression for a background-refetch spike: rank, listSize, and the + // report shape are inputs to OPENED but only `reportId` should gate the + // open/close lifecycle. + const track = vi.fn(); + const report = makeReport(); + const hook = renderTracker({ + analytics: { track }, + report, + rank: 2, + listSize: 5, + openMethod: "click", + previousReportId: null, + }); + const openedBefore = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_OPENED, + ).length; + const closedBefore = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_CLOSED, + ).length; + hook.rerender({ + analytics: { track }, + report: makeReport({ priority: "P2", actionability: "not_actionable" }), + rank: 4, + listSize: 6, + openMethod: "click", + previousReportId: null, + }); + const openedAfter = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_OPENED, + ).length; + const closedAfter = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_CLOSED, + ).length; + expect(openedAfter).toBe(openedBefore); + expect(closedAfter).toBe(closedBefore); + }); + + it("signalAction lets explicit overrides win for a different report", () => { + const track = vi.fn(); + const hook = renderTracker({ + analytics: { track }, + report: makeReport(), + rank: 0, + listSize: 1, + openMethod: "click", + previousReportId: null, + }); + act(() => { + hook.tracker().signalAction({ + report_id: "other-report", + report_title: "Other", + report_age_hours: 0, + action_type: "dismiss", + surface: "toolbar", + is_bulk: false, + bulk_size: 1, + rank: 9, + list_size: 12, + priority: "P4", + actionability: "not_actionable", + dismissal_reason: "other", + dismissal_note: "junk", + }); + }); + const actionCall = track.mock.calls.find( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_ACTION, + ); + expect(actionCall?.[1]).toMatchObject({ + report_id: "other-report", + rank: 9, + list_size: 12, + priority: "P4", + actionability: "not_actionable", + dismissal_reason: "other", + dismissal_note: "junk", + }); + }); +}); diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts new file mode 100644 index 0000000000..67ac03e03a --- /dev/null +++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useRef } from "react"; +import { + ANALYTICS_EVENTS, + type Analytics, + computeReportAgeHours, + type InboxReportActionProperties, + type InboxReportCloseMethod, + type InboxReportOpenMethod, +} from "@/lib/analytics"; +import type { SignalReport } from "../types"; + +interface OpenInfo { + reportId: string; + reportTitle: string | null; + reportCreatedAt: string | null; + reportPriority: string | null; + reportActionability: string | null; + openedAt: number; + rank: number; + listSize: number; + hasScrolled: boolean; +} + +export interface InboxEngagementTracker { + signalScroll(): void; + signalAction( + action: Omit< + InboxReportActionProperties, + "rank" | "list_size" | "priority" | "actionability" + > & { + rank?: number; + list_size?: number; + priority?: string | null; + actionability?: string | null; + }, + ): void; +} + +export interface UseInboxEngagementTrackerOptions { + analytics: Analytics; + report: SignalReport | null; + /** Rank of the report in the visible inbox list, or -1 if not in a list view. */ + rank: number; + /** Size of the visible inbox list, or 0 if not in a list view. */ + listSize: number; + /** Method that brought the user to this report. */ + openMethod: InboxReportOpenMethod; + /** Previously-opened report id; null on the first open of a session. */ + previousReportId: string | null; +} + +export function useInboxEngagementTracker( + options: UseInboxEngagementTrackerOptions, +): InboxEngagementTracker { + const { analytics, report, rank, listSize, openMethod, previousReportId } = + options; + + const openInfoRef = useRef(null); + + const analyticsRef = useRef(analytics); + analyticsRef.current = analytics; + + // Snapshot the inputs through refs so the OPENED/CLOSED lifecycle effect + // can read them without being a dep — a background list refetch (rank / + // listSize / report shape changing while the user is reading) would + // otherwise fire spurious CLOSED+OPENED pairs. + const reportRef = useRef(report); + reportRef.current = report; + const rankRef = useRef(rank); + rankRef.current = rank; + const listSizeRef = useRef(listSize); + listSizeRef.current = listSize; + const openMethodRef = useRef(openMethod); + openMethodRef.current = openMethod; + const previousReportIdRef = useRef(previousReportId); + previousReportIdRef.current = previousReportId; + + const fireClose = useCallback((closeMethod: InboxReportCloseMethod) => { + const info = openInfoRef.current; + if (!info) return; + analyticsRef.current.track(ANALYTICS_EVENTS.INBOX_REPORT_CLOSED, { + report_id: info.reportId, + report_title: info.reportTitle, + report_age_hours: computeReportAgeHours(info.reportCreatedAt), + priority: info.reportPriority, + actionability: info.reportActionability, + time_spent_ms: Date.now() - info.openedAt, + scrolled: info.hasScrolled, + close_method: closeMethod, + }); + openInfoRef.current = null; + }, []); + + const reportId = report?.id ?? null; + + useEffect(() => { + if (!reportId) return; + const snapshotReport = reportRef.current; + if (!snapshotReport) return; + + const info: OpenInfo = { + reportId, + reportTitle: snapshotReport.title ?? null, + reportCreatedAt: snapshotReport.created_at ?? null, + reportPriority: snapshotReport.priority ?? null, + reportActionability: snapshotReport.actionability ?? null, + openedAt: Date.now(), + rank: rankRef.current, + listSize: listSizeRef.current, + hasScrolled: false, + }; + openInfoRef.current = info; + + analyticsRef.current.track(ANALYTICS_EVENTS.INBOX_REPORT_OPENED, { + report_id: info.reportId, + report_title: info.reportTitle, + report_age_hours: computeReportAgeHours(info.reportCreatedAt), + status: snapshotReport.status ?? null, + priority: info.reportPriority, + actionability: info.reportActionability, + source_products: snapshotReport.source_products ?? [], + rank: info.rank, + list_size: info.listSize, + open_method: openMethodRef.current, + previous_report_id: previousReportIdRef.current, + }); + + return () => { + fireClose("deselected"); + }; + }, [reportId, fireClose]); + + const signalScroll = useCallback(() => { + const info = openInfoRef.current; + if (!info || info.hasScrolled) return; + info.hasScrolled = true; + analyticsRef.current.track(ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED, { + report_id: info.reportId, + report_title: info.reportTitle, + report_age_hours: computeReportAgeHours(info.reportCreatedAt), + priority: info.reportPriority, + actionability: info.reportActionability, + rank: info.rank, + list_size: info.listSize, + time_since_open_ms: Date.now() - info.openedAt, + }); + }, []); + + const signalAction = useCallback( + (action) => { + const info = openInfoRef.current; + const currentInfo = + info && info.reportId === action.report_id ? info : null; + const { + rank: rankOverride, + list_size: listSizeOverride, + priority: priorityOverride, + actionability: actionabilityOverride, + ...rest + } = action; + analyticsRef.current.track(ANALYTICS_EVENTS.INBOX_REPORT_ACTION, { + ...rest, + rank: + rankOverride !== undefined ? rankOverride : (currentInfo?.rank ?? -1), + list_size: + listSizeOverride !== undefined + ? listSizeOverride + : (currentInfo?.listSize ?? 0), + priority: + priorityOverride !== undefined + ? priorityOverride + : (currentInfo?.reportPriority ?? null), + actionability: + actionabilityOverride !== undefined + ? actionabilityOverride + : (currentInfo?.reportActionability ?? null), + }); + }, + [], + ); + + return { signalScroll, signalAction }; +} diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts index f8efc7baf5..0f2f3391e1 100644 --- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts @@ -19,7 +19,7 @@ export type SourceProduct = | "zendesk" | "conversations"; -const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ +export const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ "ready", "pending_input", "in_progress", diff --git a/apps/mobile/src/features/inbox/stores/inboxStore.ts b/apps/mobile/src/features/inbox/stores/inboxStore.ts index 76dded4d93..7bdb2ec206 100644 --- a/apps/mobile/src/features/inbox/stores/inboxStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxStore.ts @@ -15,6 +15,14 @@ interface InboxStoreState { skippedIds: SkippedSet; /** Index of the currently visible card in the deck */ currentIndex: number; + /** + * Snapshot of the report IDs visible in the last-rendered list view, used + * for analytics (rank + list_size) when a detail screen is opened by tapping + * a list row. + */ + lastVisibleReportIds: string[]; + /** Most recently opened report ID, used for `previous_report_id` on OPENED events. */ + previousOpenedReportId: string | null; } interface InboxStoreActions { @@ -24,6 +32,8 @@ interface InboxStoreActions { resetSkipped: () => void; setCurrentIndex: (index: number) => void; advanceCard: () => void; + setLastVisibleReportIds: (ids: string[]) => void; + setPreviousOpenedReportId: (id: string | null) => void; } type InboxStore = InboxStoreState & InboxStoreActions; @@ -33,6 +43,8 @@ export const useInboxStore = create((set) => ({ orderDirection: "desc", skippedIds: new Set(), currentIndex: 0, + lastVisibleReportIds: [], + previousOpenedReportId: null, setOrderByField: (orderByField) => set({ orderByField }), setOrderDirection: (orderDirection) => set({ orderDirection }), @@ -45,4 +57,8 @@ export const useInboxStore = create((set) => ({ resetSkipped: () => set({ skippedIds: new Set(), currentIndex: 0 }), setCurrentIndex: (currentIndex) => set({ currentIndex }), advanceCard: () => set((state) => ({ currentIndex: state.currentIndex + 1 })), + setLastVisibleReportIds: (lastVisibleReportIds) => + set({ lastVisibleReportIds }), + setPreviousOpenedReportId: (previousOpenedReportId) => + set({ previousOpenedReportId }), })); diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts new file mode 100644 index 0000000000..56c53cb951 --- /dev/null +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import type { SignalReport, SignalReportStatus } from "./types"; +import { buildInboxViewedProperties } from "./utils"; + +const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ + "ready", + "pending_input", + "in_progress", + "failed", + "candidate", + "potential", +]; + +function makeReport( + partial: Partial & Pick, +): SignalReport { + return { + title: null, + summary: null, + status: "ready", + total_weight: 0, + signal_count: 0, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + artefact_count: 0, + ...partial, + }; +} + +describe("buildInboxViewedProperties", () => { + it("emits zero counts for an empty list", () => { + const props = buildInboxViewedProperties([], 0, { + sourceProductFilter: [], + statusFilter: DEFAULT_STATUS_FILTER, + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(props).toMatchObject({ + report_count: 0, + total_count: 0, + ready_count: 0, + has_active_filters: false, + is_empty: true, + is_gated_due_to_scale: false, + priority_p0_count: 0, + priority_p1_count: 0, + priority_p2_count: 0, + priority_p3_count: 0, + priority_p4_count: 0, + priority_unknown_count: 0, + actionability_immediately_actionable_count: 0, + actionability_requires_human_input_count: 0, + actionability_not_actionable_count: 0, + actionability_unknown_count: 0, + }); + }); + + it("breaks visible reports down by priority and actionability", () => { + const reports: SignalReport[] = [ + makeReport({ + id: "1", + priority: "P0", + actionability: "immediately_actionable", + status: "ready", + }), + makeReport({ + id: "2", + priority: "P2", + actionability: "requires_human_input", + status: "ready", + }), + makeReport({ + id: "3", + priority: "P2", + actionability: "not_actionable", + status: "potential", + }), + makeReport({ id: "4", status: "failed" }), + ]; + + const props = buildInboxViewedProperties(reports, 4, { + sourceProductFilter: [], + statusFilter: DEFAULT_STATUS_FILTER, + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + + expect(props.report_count).toBe(4); + expect(props.total_count).toBe(4); + expect(props.ready_count).toBe(2); + expect(props.priority_p0_count).toBe(1); + expect(props.priority_p2_count).toBe(2); + expect(props.priority_unknown_count).toBe(1); + expect(props.actionability_immediately_actionable_count).toBe(1); + expect(props.actionability_requires_human_input_count).toBe(1); + expect(props.actionability_not_actionable_count).toBe(1); + expect(props.actionability_unknown_count).toBe(1); + }); + + it("marks filters active when any of status/source/reviewer differs from defaults", () => { + const narrowed = buildInboxViewedProperties([], 0, { + sourceProductFilter: [], + statusFilter: ["ready"], + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(narrowed.has_active_filters).toBe(true); + expect(narrowed.status_filter_count).toBe(1); + + const sourced = buildInboxViewedProperties([], 0, { + sourceProductFilter: ["error_tracking"], + statusFilter: DEFAULT_STATUS_FILTER, + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(sourced.has_active_filters).toBe(true); + expect(sourced.source_product_filter).toEqual(["error_tracking"]); + + const reviewer = buildInboxViewedProperties([], 0, { + sourceProductFilter: [], + statusFilter: DEFAULT_STATUS_FILTER, + suggestedReviewerFilter: ["uuid-1"], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(reviewer.has_active_filters).toBe(true); + }); + + it("treats a reordered default status set as not filtered", () => { + const props = buildInboxViewedProperties([], 0, { + sourceProductFilter: [], + statusFilter: [...DEFAULT_STATUS_FILTER].reverse(), + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(props.has_active_filters).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts index 588978b49f..2ac3de63ee 100644 --- a/apps/mobile/src/features/inbox/utils.ts +++ b/apps/mobile/src/features/inbox/utils.ts @@ -1,3 +1,4 @@ +import type { InboxViewedProperties } from "@/lib/analytics"; import type { SignalReport, SignalReportOrderingField, @@ -87,3 +88,89 @@ export function getActionableReports(reports: SignalReport[]): SignalReport[] { !r.already_addressed, ); } + +interface InboxViewedFilterState { + sourceProductFilter: string[]; + statusFilter: SignalReportStatus[]; + suggestedReviewerFilter: string[]; + /** Default status filter as defined in the filter store, used to detect whether the user has narrowed it. */ + defaultStatusFilter: SignalReportStatus[]; +} + +/** + * Build the property payload for the `Inbox viewed` analytics event. + * + * Mirrors apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx so + * desktop and mobile send the same shape into PostHog. + */ +export function buildInboxViewedProperties( + reports: SignalReport[], + totalCount: number, + filters: InboxViewedFilterState, +): InboxViewedProperties { + const priorityCounts = { + P0: 0, + P1: 0, + P2: 0, + P3: 0, + P4: 0, + unknown: 0, + }; + const actionabilityCounts = { + immediately_actionable: 0, + requires_human_input: 0, + not_actionable: 0, + unknown: 0, + }; + let readyCount = 0; + for (const r of reports) { + if (r.status === "ready") readyCount += 1; + const p = r.priority; + if (p === "P0" || p === "P1" || p === "P2" || p === "P3" || p === "P4") { + priorityCounts[p] += 1; + } else { + priorityCounts.unknown += 1; + } + const a = r.actionability; + if ( + a === "immediately_actionable" || + a === "requires_human_input" || + a === "not_actionable" + ) { + actionabilityCounts[a] += 1; + } else { + actionabilityCounts.unknown += 1; + } + } + + const statusFiltered = + filters.statusFilter.length !== filters.defaultStatusFilter.length || + filters.statusFilter.some((s) => !filters.defaultStatusFilter.includes(s)); + const hasActiveFilters = + statusFiltered || + filters.sourceProductFilter.length > 0 || + filters.suggestedReviewerFilter.length > 0; + + return { + report_count: reports.length, + total_count: totalCount, + ready_count: readyCount, + has_active_filters: hasActiveFilters, + source_product_filter: filters.sourceProductFilter, + status_filter_count: filters.statusFilter.length, + is_empty: totalCount === 0, + is_gated_due_to_scale: false, + priority_p0_count: priorityCounts.P0, + priority_p1_count: priorityCounts.P1, + priority_p2_count: priorityCounts.P2, + priority_p3_count: priorityCounts.P3, + priority_p4_count: priorityCounts.P4, + priority_unknown_count: priorityCounts.unknown, + actionability_immediately_actionable_count: + actionabilityCounts.immediately_actionable, + actionability_requires_human_input_count: + actionabilityCounts.requires_human_input, + actionability_not_actionable_count: actionabilityCounts.not_actionable, + actionability_unknown_count: actionabilityCounts.unknown, + }; +} diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 9e7ec5a54a..2cb96dba56 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -7,6 +7,8 @@ export interface Task { created_at: string; updated_at: string; origin_product: string; + /** Inbox report UUID when origin_product is "signal_report". */ + signal_report?: string | null; repository?: string | null; github_integration?: number | null; internal?: boolean; diff --git a/apps/mobile/src/lib/analytics.test.ts b/apps/mobile/src/lib/analytics.test.ts new file mode 100644 index 0000000000..f0f1e104cc --- /dev/null +++ b/apps/mobile/src/lib/analytics.test.ts @@ -0,0 +1,96 @@ +import { createElement } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + computeReportAgeHours, + useActiveTaskAnalyticsContext, +} from "./analytics"; + +const mockPosthog = { + register: vi.fn(), + unregister: vi.fn(), + capture: vi.fn(), +}; + +vi.mock("posthog-react-native", () => ({ + usePostHog: () => mockPosthog, +})); + +describe("computeReportAgeHours", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T12:00:00Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns 0 for null/undefined input", () => { + expect(computeReportAgeHours(null)).toBe(0); + expect(computeReportAgeHours(undefined)).toBe(0); + }); + + it("rounds to one decimal", () => { + // 1h 35m before "now" → 1.6h + expect(computeReportAgeHours("2026-01-01T10:25:00Z")).toBe(1.6); + }); + + it("clamps at 0 when clock skew puts createdAt in the future", () => { + expect(computeReportAgeHours("2026-01-02T00:00:00Z")).toBe(0); + }); +}); + +function renderActiveTaskHook(initial: string | null) { + let currentId: string | null = initial; + function Wrapper() { + useActiveTaskAnalyticsContext(currentId); + return null; + } + let renderer: ReactTestRenderer | null = null; + act(() => { + renderer = create(createElement(Wrapper)); + }); + return { + rerender: (id: string | null) => { + currentId = id; + act(() => { + renderer?.update(createElement(Wrapper)); + }); + }, + unmount: () => { + act(() => { + renderer?.unmount(); + }); + }, + }; +} + +describe("useActiveTaskAnalyticsContext", () => { + beforeEach(() => { + mockPosthog.register.mockClear(); + mockPosthog.unregister.mockClear(); + }); + + it("registers and unregisters signal_report_id as the prop changes", () => { + const hook = renderActiveTaskHook("report-1"); + expect(mockPosthog.register).toHaveBeenCalledWith({ + signal_report_id: "report-1", + }); + + hook.rerender("report-2"); + expect(mockPosthog.unregister).toHaveBeenCalledWith("signal_report_id"); + expect(mockPosthog.register).toHaveBeenLastCalledWith({ + signal_report_id: "report-2", + }); + + hook.unmount(); + expect(mockPosthog.unregister).toHaveBeenLastCalledWith("signal_report_id"); + }); + + it("never registers when the id is null", () => { + const hook = renderActiveTaskHook(null); + expect(mockPosthog.register).not.toHaveBeenCalled(); + expect(mockPosthog.unregister).not.toHaveBeenCalled(); + hook.unmount(); + }); +}); diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts new file mode 100644 index 0000000000..e9ccebdca2 --- /dev/null +++ b/apps/mobile/src/lib/analytics.ts @@ -0,0 +1,198 @@ +import type { PostHogEventProperties } from "@posthog/core"; +import { usePostHog } from "posthog-react-native"; +import { useEffect, useMemo } from "react"; + +/** + * Event names mirror apps/code/src/shared/types/analytics.ts so PostHog reports + * funnel the same events from desktop and mobile into a single bucket. + */ +export const ANALYTICS_EVENTS = { + INBOX_VIEWED: "Inbox viewed", + INBOX_REPORT_OPENED: "Inbox report opened", + INBOX_REPORT_CLOSED: "Inbox report closed", + INBOX_REPORT_SCROLLED: "Inbox report scrolled", + INBOX_REPORT_ACTION: "Inbox report action", +} as const; + +export type InboxReportOpenMethod = + | "click" + | "click_cmd" + | "click_shift" + | "keyboard" + | "deeplink" + | "unknown"; + +export type InboxReportCloseMethod = + | "next_report" + | "deselected" + | "navigated_away" + | "unmount"; + +export type InboxReportActionType = + | "dismiss" + | "snooze" + | "delete" + | "reingest" + | "create_pr" + | "open_pr" + | "copy_link" + | "discuss" + | "expand_signal" + | "collapse_signal" + | "expand_signal_section" + | "view_signal_external" + | "expand_why" + | "click_suggested_reviewer" + | "expand_task_section" + | "play_session_recording"; + +export type InboxReportActionSurface = + | "detail_pane" + | "toolbar" + | "keyboard" + | "list_row"; + +export interface InboxViewedProperties { + report_count: number; + total_count: number; + ready_count: number; + has_active_filters: boolean; + source_product_filter: string[]; + status_filter_count: number; + is_empty: boolean; + is_gated_due_to_scale: boolean; + priority_p0_count: number; + priority_p1_count: number; + priority_p2_count: number; + priority_p3_count: number; + priority_p4_count: number; + priority_unknown_count: number; + actionability_immediately_actionable_count: number; + actionability_requires_human_input_count: number; + actionability_not_actionable_count: number; + actionability_unknown_count: number; +} + +export interface InboxReportOpenedProperties { + report_id: string; + report_title: string | null; + report_age_hours: number; + status: string | null; + priority: string | null; + actionability: string | null; + source_products: string[]; + rank: number; + list_size: number; + open_method: InboxReportOpenMethod; + previous_report_id: string | null; +} + +export interface InboxReportClosedProperties { + report_id: string; + report_title: string | null; + report_age_hours: number; + priority: string | null; + actionability: string | null; + time_spent_ms: number; + scrolled: boolean; + close_method: InboxReportCloseMethod; +} + +export interface InboxReportScrolledProperties { + report_id: string; + report_title: string | null; + report_age_hours: number; + priority: string | null; + actionability: string | null; + rank: number; + list_size: number; + time_since_open_ms: number; +} + +export interface InboxReportActionProperties { + report_id: string; + report_title: string | null; + report_age_hours: number; + priority: string | null; + actionability: string | null; + action_type: InboxReportActionType; + surface: InboxReportActionSurface; + is_bulk: boolean; + bulk_size: number; + rank: number; + list_size: number; + dismissal_reason?: string; + dismissal_note?: string; + signal_id?: string; + signal_source_product?: string; + signal_source_type?: string; + signal_section?: "relevant_code" | "data_queried"; + why_field?: "priority" | "actionability"; + task_section?: "research" | "implementation"; + has_question?: boolean; + question_text?: string; +} + +export type EventPropertyMap = { + [ANALYTICS_EVENTS.INBOX_VIEWED]: InboxViewedProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_OPENED]: InboxReportOpenedProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_CLOSED]: InboxReportClosedProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED]: InboxReportScrolledProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_ACTION]: InboxReportActionProperties; +}; + +export interface Analytics { + track( + eventName: K, + properties: EventPropertyMap[K], + ): void; +} + +export function useAnalytics(): Analytics { + const posthog = usePostHog(); + return useMemo( + () => ({ + track: (eventName, properties) => { + // Our typed property interfaces don't carry an index signature; cast + // to the wider PostHog event-properties shape without losing the + // narrower call-site type-check. + posthog?.capture( + eventName, + properties as unknown as PostHogEventProperties, + ); + }, + }), + [posthog], + ); +} + +/** + * Tag every subsequent PostHog event with `signal_report_id` for as long as + * the calling screen is mounted with a non-null `signalReportId`. Clears the + * super-property on unmount or when `signalReportId` becomes null. Mirrors the + * desktop `setActiveTaskAnalyticsContext` super-property behaviour so events + * fired while inside a discuss-launched task can be filtered down to a single + * inbox report. + */ +export function useActiveTaskAnalyticsContext( + signalReportId: string | null | undefined, +): void { + const posthog = usePostHog(); + useEffect(() => { + if (!posthog || !signalReportId) return; + posthog.register({ signal_report_id: signalReportId }); + return () => { + posthog.unregister("signal_report_id"); + }; + }, [posthog, signalReportId]); +} + +/** Report age at fire time in hours, rounded to one decimal. Clamped at 0 to guard against clock skew. */ +export function computeReportAgeHours( + createdAt: string | null | undefined, +): number { + if (!createdAt) return 0; + const ageMs = Date.now() - new Date(createdAt).getTime(); + if (!Number.isFinite(ageMs)) return 0; + return Math.max(0, Math.round((ageMs / 3_600_000) * 10) / 10); +}