fix(analytics): defer insight views until foregrounded

Only count insight views when the page is visible and focused. Defer background activations until the first foreground transition without resuming cancelled tab dwell timers, and cover focus and visibility ordering in regression tests.
This commit is contained in:
digua committed 2026-09-08 15:52:11 +08:00
1 parent b9903e77e6
commit 0e8339bc4c
2 files changed
+76 -3

No files matched your search

+56 -1
View File
@@ -9,7 +9,8 @@ test('only uninterrupted foreground selections count; initial views and cancella
})
const { useInsightTabAnalytics } = await import('./useInsightTabAnalytics')
const browserWindow = new EventTarget()
const browserDocument = Object.assign(new EventTarget(), { visibilityState: 'visible', hasFocus: () => true })
let focused = true
const browserDocument = Object.assign(new EventTarget(), { visibilityState: 'visible', hasFocus: () => focused })
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document')
Object.defineProperty(globalThis, 'window', { configurable: true, value: browserWindow })
@@ -83,6 +84,60 @@ test('only uninterrupted foreground selections count; initial views and cancella
select('overview')
t.mock.timers.tick(3000)
assert.equal(visits().length, 2)
// Loading can finish in a hidden tab or an unfocused window. Neither is a view yet.
const views = () => events.filter(({ name }) => name === 'insight_viewed').length
for (const [visibility, returnEvent] of [
['hidden', 'focus'],
['visible', 'focus'],
['hidden', 'visibilitychange'],
]) {
active.value = false
focused = false
browserDocument.visibilityState = visibility
const before = views()
active.value = true
assert.equal(views(), before)
if (returnEvent === 'focus') {
browserDocument.visibilityState = 'visible'
browserDocument.dispatchEvent(new Event('visibilitychange'))
} else {
focused = true
browserWindow.dispatchEvent(new Event('focus'))
}
assert.equal(views(), before)
focused = true
browserDocument.visibilityState = 'visible'
if (returnEvent === 'focus') browserWindow.dispatchEvent(new Event('focus'))
else browserDocument.dispatchEvent(new Event('visibilitychange'))
assert.equal(views(), before + 1)
browserWindow.dispatchEvent(new Event('focus'))
browserDocument.dispatchEvent(new Event('visibilitychange'))
assert.equal(views(), before + 1)
t.mock.timers.tick(5000)
assert.equal(visits().length, 2)
}
// Returning to the foreground cannot revive a cancelled dwell visit.
select('topic')
t.mock.timers.tick(2000)
focused = false
browserWindow.dispatchEvent(new Event('blur'))
focused = true
browserWindow.dispatchEvent(new Event('focus'))
t.mock.timers.tick(5000)
assert.equal(visits().length, 2)
// Leaving before the first foreground view must discard the pending denominator event.
active.value = false
focused = false
active.value = true
const beforeLeaving = views()
active.value = false
focused = true
browserWindow.dispatchEvent(new Event('focus'))
assert.equal(views(), beforeLeaving)
active.value = true
select('topic')
scope.stop()
t.mock.timers.tick(3000)
+20 -2
View File
@@ -9,6 +9,7 @@ export function useInsightTabAnalytics(options: {
routePath: () => string
}) {
let timer: ReturnType<typeof setTimeout> | undefined
let pendingView = false
function cancel() {
clearTimeout(timer)
@@ -17,6 +18,12 @@ export function useInsightTabAnalytics(options: {
const isForeground = () => document.visibilityState === 'visible' && document.hasFocus()
function reportPendingView() {
if (!pendingView || !options.isActive() || !options.sessionId.value || !isForeground()) return
pendingView = false
trackProductEvent('insight_viewed', { chat_type: options.chatType })
}
function selectTab(tabId: string) {
cancel()
if (!options.isActive() || !isForeground()) return
@@ -35,21 +42,32 @@ export function useInsightTabAnalytics(options: {
([active, sessionId]) => {
cancel()
// Separate denominator: old clients without sub-tab tracking must not dilute its adoption rate.
if (active && sessionId) trackProductEvent('insight_viewed', { chat_type: options.chatType })
pendingView = Boolean(active && sessionId)
reportPendingView()
},
{ immediate: true, flush: 'sync' }
)
function onVisibilityChange() {
if (document.visibilityState !== 'visible') cancel()
else reportPendingView()
}
window.addEventListener('blur', cancel)
window.addEventListener('focus', reportPendingView)
// Cancel as soon as navigation commits, before an outgoing page transition finishes.
watch(options.routePath, cancel, { flush: 'sync' })
watch(
options.routePath,
() => {
cancel()
pendingView = false
},
{ flush: 'sync' }
)
document.addEventListener('visibilitychange', onVisibilityChange)
onScopeDispose(() => {
cancel()
window.removeEventListener('blur', cancel)
window.removeEventListener('focus', reportPendingView)
document.removeEventListener('visibilitychange', onVisibilityChange)
})