From 0e8339bc4c09f4d23cbe46ace1e40ad098038673 Mon Sep 17 00:00:00 2001 From: digua Date: Tue, 8 Sep 2026 15:46:32 +0800 Subject: [PATCH] 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. --- .../useInsightTabAnalytics.test.ts | 57 ++++++++++++++++++- src/composables/useInsightTabAnalytics.ts | 22 ++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/composables/useInsightTabAnalytics.test.ts b/src/composables/useInsightTabAnalytics.test.ts index 77c31835..f1d9ab05 100644 --- a/src/composables/useInsightTabAnalytics.test.ts +++ b/src/composables/useInsightTabAnalytics.test.ts @@ -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) diff --git a/src/composables/useInsightTabAnalytics.ts b/src/composables/useInsightTabAnalytics.ts index 4543897f..4cd72ddb 100644 --- a/src/composables/useInsightTabAnalytics.ts +++ b/src/composables/useInsightTabAnalytics.ts @@ -9,6 +9,7 @@ export function useInsightTabAnalytics(options: { routePath: () => string }) { let timer: ReturnType | 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) })