Skip to content

Commit 5b4bc01

Browse files
authored
Merge pull request #8606 from Couchers-org/web/feature/search-funnel-instrumentation
Instrument search → request funnel
2 parents 5ead923 + 11a5580 commit 5b4bc01

13 files changed

Lines changed: 830 additions & 35 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Accumulates wall-clock and foreground (document visible) time since creation.
3+
*
4+
* Wire `onVisibilityChange` to the document's `visibilitychange` event, then
5+
* call `finalize()` once (e.g. on unmount) to read the totals.
6+
*/
7+
export interface ForegroundTracker {
8+
onVisibilityChange: () => void;
9+
finalize: () => { foregroundMs: number; totalMs: number };
10+
}
11+
12+
export function createForegroundTracker(): ForegroundTracker {
13+
const startedAt = performance.now();
14+
let foregroundAccumMs = 0;
15+
let visibleSince: number | null =
16+
typeof document !== "undefined" && document.visibilityState === "visible"
17+
? startedAt
18+
: null;
19+
20+
return {
21+
onVisibilityChange() {
22+
const now = performance.now();
23+
if (document.visibilityState === "visible") {
24+
if (visibleSince === null) visibleSince = now;
25+
} else if (visibleSince !== null) {
26+
foregroundAccumMs += now - visibleSince;
27+
visibleSince = null;
28+
}
29+
},
30+
finalize() {
31+
const now = performance.now();
32+
if (visibleSince !== null) {
33+
foregroundAccumMs += now - visibleSince;
34+
visibleSince = null;
35+
}
36+
return {
37+
foregroundMs: Math.round(foregroundAccumMs),
38+
totalMs: Math.round(now - startedAt),
39+
};
40+
},
41+
};
42+
}

app/web/features/analytics/hooks.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,4 +355,152 @@ describe("useImpressionRef", () => {
355355

356356
expect(mockLogEvent).not.toHaveBeenCalled();
357357
});
358+
359+
describe("with minDurationMs", () => {
360+
beforeEach(() => {
361+
jest.useFakeTimers();
362+
});
363+
364+
afterEach(() => {
365+
jest.useRealTimers();
366+
});
367+
368+
it("fires after the element is continuously visible for the duration", () => {
369+
const { result } = renderHook(() =>
370+
useImpressionRef(
371+
"card.viewed",
372+
{ card_id: "1" },
373+
{ minDurationMs: 250 },
374+
),
375+
);
376+
377+
const node = document.createElement("div");
378+
act(() => {
379+
result.current(node);
380+
});
381+
382+
act(() => {
383+
lastCallback(
384+
[{ isIntersecting: true } as IntersectionObserverEntry],
385+
{} as IntersectionObserver,
386+
);
387+
});
388+
389+
expect(mockLogEvent).not.toHaveBeenCalled();
390+
391+
act(() => {
392+
jest.advanceTimersByTime(250);
393+
});
394+
395+
expect(mockLogEvent).toHaveBeenCalledTimes(1);
396+
expect(mockLogEvent).toHaveBeenCalledWith("card.viewed", {
397+
card_id: "1",
398+
});
399+
expect(mockDisconnect).toHaveBeenCalled();
400+
});
401+
402+
it("does not fire if the element leaves before the duration elapses", () => {
403+
const { result } = renderHook(() =>
404+
useImpressionRef("card.viewed", {}, { minDurationMs: 250 }),
405+
);
406+
407+
const node = document.createElement("div");
408+
act(() => {
409+
result.current(node);
410+
});
411+
412+
act(() => {
413+
lastCallback(
414+
[{ isIntersecting: true } as IntersectionObserverEntry],
415+
{} as IntersectionObserver,
416+
);
417+
});
418+
419+
act(() => {
420+
jest.advanceTimersByTime(100);
421+
});
422+
423+
act(() => {
424+
lastCallback(
425+
[{ isIntersecting: false } as IntersectionObserverEntry],
426+
{} as IntersectionObserver,
427+
);
428+
});
429+
430+
act(() => {
431+
jest.advanceTimersByTime(500);
432+
});
433+
434+
expect(mockLogEvent).not.toHaveBeenCalled();
435+
});
436+
437+
it("restarts the timer when the element re-enters after leaving", () => {
438+
const { result } = renderHook(() =>
439+
useImpressionRef("card.viewed", {}, { minDurationMs: 250 }),
440+
);
441+
442+
const node = document.createElement("div");
443+
act(() => {
444+
result.current(node);
445+
});
446+
447+
act(() => {
448+
lastCallback(
449+
[{ isIntersecting: true } as IntersectionObserverEntry],
450+
{} as IntersectionObserver,
451+
);
452+
});
453+
act(() => {
454+
jest.advanceTimersByTime(100);
455+
});
456+
act(() => {
457+
lastCallback(
458+
[{ isIntersecting: false } as IntersectionObserverEntry],
459+
{} as IntersectionObserver,
460+
);
461+
});
462+
463+
// Re-enter — timer should restart from zero
464+
act(() => {
465+
lastCallback(
466+
[{ isIntersecting: true } as IntersectionObserverEntry],
467+
{} as IntersectionObserver,
468+
);
469+
});
470+
act(() => {
471+
jest.advanceTimersByTime(249);
472+
});
473+
expect(mockLogEvent).not.toHaveBeenCalled();
474+
475+
act(() => {
476+
jest.advanceTimersByTime(1);
477+
});
478+
expect(mockLogEvent).toHaveBeenCalledTimes(1);
479+
});
480+
481+
it("clears the pending timer on unmount", () => {
482+
const { result, unmount } = renderHook(() =>
483+
useImpressionRef("card.viewed", {}, { minDurationMs: 250 }),
484+
);
485+
486+
const node = document.createElement("div");
487+
act(() => {
488+
result.current(node);
489+
});
490+
act(() => {
491+
lastCallback(
492+
[{ isIntersecting: true } as IntersectionObserverEntry],
493+
{} as IntersectionObserver,
494+
);
495+
});
496+
497+
unmount();
498+
499+
act(() => {
500+
jest.advanceTimersByTime(500);
501+
});
502+
503+
expect(mockLogEvent).not.toHaveBeenCalled();
504+
});
505+
});
358506
});

app/web/features/analytics/hooks.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,46 +55,76 @@ export function useDurationEvent(
5555
}
5656

5757
/**
58-
* Returns a callback ref. When the element enters the viewport,
59-
* fires the event once via IntersectionObserver.
58+
* Returns a callback ref. Fires the event once via IntersectionObserver after
59+
* the element has been continuously >= `threshold` visible for `minDurationMs`
60+
* (default 0 = fires on first intersection).
6061
*
61-
* `properties` and `threshold` are stored in refs so callers don't
62-
* need to memoize them — the observer is only recreated when `eventType` changes.
62+
* `properties`, `threshold`, and `minDurationMs` are stored in refs so callers
63+
* don't need to memoize them — the observer is only recreated when
64+
* `eventType` changes.
6365
*/
6466
export function useImpressionRef(
6567
eventType: string,
6668
properties: Record<string, unknown> = {},
67-
options?: { threshold?: number },
69+
options?: { threshold?: number; minDurationMs?: number },
6870
) {
6971
const observerRef = useRef<IntersectionObserver | null>(null);
72+
const visibleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
7073
const hasFiredRef = useRef(false);
7174
const propsRef = useRef(properties);
7275
propsRef.current = properties;
7376
const thresholdRef = useRef(options?.threshold ?? 0.5);
7477
thresholdRef.current = options?.threshold ?? 0.5;
78+
const minDurationRef = useRef(options?.minDurationMs ?? 0);
79+
minDurationRef.current = options?.minDurationMs ?? 0;
7580

7681
// Cleanup on unmount
7782
useEffect(() => {
7883
return () => {
7984
observerRef.current?.disconnect();
85+
if (visibleTimerRef.current) {
86+
clearTimeout(visibleTimerRef.current);
87+
visibleTimerRef.current = null;
88+
}
8089
};
8190
}, []);
8291

8392
const callbackRef = useCallback(
8493
(node: HTMLElement | null) => {
8594
// Disconnect previous observer
8695
observerRef.current?.disconnect();
96+
if (visibleTimerRef.current) {
97+
clearTimeout(visibleTimerRef.current);
98+
visibleTimerRef.current = null;
99+
}
87100

88101
if (!node || hasFiredRef.current) return;
89102

103+
const fire = () => {
104+
if (hasFiredRef.current) return;
105+
hasFiredRef.current = true;
106+
logEvent(eventType, propsRef.current);
107+
observerRef.current?.disconnect();
108+
};
109+
90110
observerRef.current = new IntersectionObserver(
91111
(entries) => {
92112
for (const entry of entries) {
93-
if (entry.isIntersecting && !hasFiredRef.current) {
94-
hasFiredRef.current = true;
95-
logEvent(eventType, propsRef.current);
96-
observerRef.current?.disconnect();
97-
break;
113+
if (hasFiredRef.current) return;
114+
if (entry.isIntersecting) {
115+
if (minDurationRef.current === 0) {
116+
fire();
117+
return;
118+
}
119+
if (!visibleTimerRef.current) {
120+
visibleTimerRef.current = setTimeout(() => {
121+
visibleTimerRef.current = null;
122+
fire();
123+
}, minDurationRef.current);
124+
}
125+
} else if (visibleTimerRef.current) {
126+
clearTimeout(visibleTimerRef.current);
127+
visibleTimerRef.current = null;
98128
}
99129
}
100130
},
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { createContext, ReactNode, useContext, useMemo } from "react";
2+
3+
import { getOrCreateSearchSessionId } from "./searchAttribution";
4+
5+
interface SearchAnalyticsValue {
6+
searchSessionId: string;
7+
searchQueryId: string;
8+
pageNumber: number;
9+
}
10+
11+
const SearchAnalyticsContext = createContext<SearchAnalyticsValue | null>(null);
12+
13+
export function SearchAnalyticsProvider({
14+
children,
15+
searchQueryId,
16+
pageNumber,
17+
}: {
18+
children: ReactNode;
19+
searchQueryId: string;
20+
pageNumber: number;
21+
}) {
22+
const searchSessionId = useMemo(() => getOrCreateSearchSessionId(), []);
23+
const value = useMemo(
24+
() => ({ searchSessionId, searchQueryId, pageNumber }),
25+
[searchSessionId, searchQueryId, pageNumber],
26+
);
27+
return (
28+
<SearchAnalyticsContext.Provider value={value}>
29+
{children}
30+
</SearchAnalyticsContext.Provider>
31+
);
32+
}
33+
34+
export function useSearchAnalytics(): SearchAnalyticsValue | null {
35+
return useContext(SearchAnalyticsContext);
36+
}

0 commit comments

Comments
 (0)