From 3a27b63024ceb9fc4521d8056b25a45e9af0faea Mon Sep 17 00:00:00 2001 From: Pedram Valiani Date: Fri, 20 Feb 2026 14:55:05 +0000 Subject: [PATCH 01/13] add types for skip rate --- src/app/components/ATIAnalytics/types.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/components/ATIAnalytics/types.ts b/src/app/components/ATIAnalytics/types.ts index ac6fd73dbd8..e2ff13d5ecb 100644 --- a/src/app/components/ATIAnalytics/types.ts +++ b/src/app/components/ATIAnalytics/types.ts @@ -174,6 +174,13 @@ export interface ItemTracker { text?: string; position?: number; duration?: number; + // these fields support portrait video skip-rate analysis in reverb/piano + totalDuration?: number; + completionRate?: number; + skipRate?: number; + navigationMethod?: string; + exitReason?: string; + versionId?: string; resourceId?: string; label?: string; mediaType?: string; From a9e752db1f3a14433e941c5635550fc9e723aab5 Mon Sep 17 00:00:00 2001 From: Pedram Valiani Date: Fri, 20 Feb 2026 14:56:47 +0000 Subject: [PATCH 02/13] add event tracking optional fields for total duration, completion rate etc --- src/app/components/ATIAnalytics/atiUrl/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/app/components/ATIAnalytics/atiUrl/index.ts b/src/app/components/ATIAnalytics/atiUrl/index.ts index f95fd1a6ffe..79b438a41b7 100644 --- a/src/app/components/ATIAnalytics/atiUrl/index.ts +++ b/src/app/components/ATIAnalytics/atiUrl/index.ts @@ -113,11 +113,18 @@ export const buildReverbEventModel = ({ groupTracker = {}, eventGroupingName, }: ATIEventTrackingProps): ReverbBeaconConfig => { + // these optional fields are emitted by the portrait video skip-rate spike const { type: itemType, text, position, duration, + totalDuration, + completionRate, + skipRate, + navigationMethod, + exitReason, + versionId, label, mediaType, resourceId: itemResourceId, @@ -156,6 +163,13 @@ export const buildReverbEventModel = ({ ...(text && { text }), ...(position && { position }), ...(duration && { duration }), + // these are mapped to snake_case so the payload matches existing reverb conventions + ...(totalDuration && { total_duration: totalDuration }), + ...(completionRate != null && { completion_rate: completionRate }), + ...(skipRate != null && { skip_rate: skipRate }), + ...(navigationMethod && { navigation_method: navigationMethod }), + ...(exitReason && { exit_reason: exitReason }), + ...(versionId && { version_id: versionId }), ...(mediaType && { media_type: mediaType }), ...(label && { label }), ...(itemResourceId && { resource_id: itemResourceId }), From ce57fe540c73516e330670dcabeb3f3ba035fb29 Mon Sep 17 00:00:00 2001 From: Pedram Valiani Date: Fri, 20 Feb 2026 14:57:09 +0000 Subject: [PATCH 03/13] create new event grouping name for swipe tracking --- src/app/hooks/useSwipeTracker/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/hooks/useSwipeTracker/index.tsx b/src/app/hooks/useSwipeTracker/index.tsx index 66dd2ca958a..cdda37a275c 100644 --- a/src/app/hooks/useSwipeTracker/index.tsx +++ b/src/app/hooks/useSwipeTracker/index.tsx @@ -33,6 +33,8 @@ const getComponentSwipeTracker = (eventTrackingData?: EventTrackingData) => { statsDestination, campaignID, detailedPlacement, + // carries a custom event grouping through to reverb/piano + eventGroupingName, groupTracker, itemTracker, alwaysInView = false, @@ -57,6 +59,7 @@ const getComponentSwipeTracker = (eventTrackingData?: EventTrackingData) => { advertiserID, url, detailedPlacement, + eventGroupingName, ...(groupTracker && { groupTracker }), ...(itemTracker && { itemTracker }), }, From be83377670e91ee30969d4f928dbb52e86f5f394 Mon Sep 17 00:00:00 2001 From: Pedram Valiani Date: Fri, 20 Feb 2026 14:57:28 +0000 Subject: [PATCH 04/13] add event grouping to ati tracking props --- src/app/lib/analyticsUtils/extractATITrackingProps/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/lib/analyticsUtils/extractATITrackingProps/index.ts b/src/app/lib/analyticsUtils/extractATITrackingProps/index.ts index 37d0d2010c8..f3f75fedab4 100644 --- a/src/app/lib/analyticsUtils/extractATITrackingProps/index.ts +++ b/src/app/lib/analyticsUtils/extractATITrackingProps/index.ts @@ -20,6 +20,8 @@ export default ({ sendOptimizelyEvents, experimentName, experimentVariant, + // allows custom events to set a grouping label in reverb/piano + eventGroupingName, itemTracker, groupTracker, viewThreshold, @@ -54,6 +56,7 @@ export default ({ sendOptimizelyEvents, experimentName, experimentVariant, + eventGroupingName, itemTracker, groupTracker, viewThreshold, From 49129bec0444f60943c645eb0835e00eb0a0c504 Mon Sep 17 00:00:00 2001 From: Pedram Valiani Date: Fri, 20 Feb 2026 14:58:21 +0000 Subject: [PATCH 05/13] adds skip rate tracking logic to portrait video modal --- .../components/PortraitVideoModal/index.tsx | 307 ++++++++++++++++-- 1 file changed, 288 insertions(+), 19 deletions(-) diff --git a/src/app/components/PortraitVideoModal/index.tsx b/src/app/components/PortraitVideoModal/index.tsx index 750395d8a6b..ebaf3deb1e1 100644 --- a/src/app/components/PortraitVideoModal/index.tsx +++ b/src/app/components/PortraitVideoModal/index.tsx @@ -1,7 +1,8 @@ import { Global } from '@emotion/react'; -import { use, useEffect, useRef } from 'react'; +import { use, useCallback, useEffect, useRef } from 'react'; import moment from 'moment-timezone'; import MediaLoader from '#app/components/MediaLoader'; +import { GROUP_3_MIN_WIDTH_BP } from '#app/components/ThemeProvider/mediaQueries'; import { Player, Playlist, @@ -18,6 +19,29 @@ import styles from './index.styles'; import VisuallyHiddenText from '../VisuallyHiddenText'; import { DownArrowIcon, UpArrowIcon } from '../icons'; +// disabled so skip-rate tracking runs on both mobile and desktop. +const MOBILE_ONLY_SKIP_RATE_TRACKING = false; +const MOBILE_BREAKPOINT_QUERY = `(max-width: ${GROUP_3_MIN_WIDTH_BP}rem)`; +// this name lets reverb/piano group all skip-rate events together. +const SKIP_RATE_COMPONENT_NAME = 'portrait-video-skip-rate'; +const SKIP_RATE_EVENT_GROUPING_NAME = 'portrait-video-skip-rate'; + +// reasoning for end of video tracked viewing session +type SkipTrackingExitReason = + | 'navigation' + | 'autoplay-end' + | 'playlist-sync' + | 'close-button' + | 'backdrop' + | 'escape' + | 'fullscreen-exit' + | 'unmount'; + +type ActiveVideoSession = { + index: number; + startedAt: number; +}; + type ModalTrackingParameters = { eventTrackingData: EventTrackingData; selectedVideo: PortraitClipMediaBlock; @@ -53,6 +77,47 @@ const getEventTrackingData = ({ }; }; +// this gates skip-rate tracking so we can ship mobile first and expand later +const shouldTrackSkipRate = () => { + if (!MOBILE_ONLY_SKIP_RATE_TRACKING) { + return true; + } + + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia(MOBILE_BREAKPOINT_QUERY).matches + ); +}; + +const isValidVideoIndex = ( + videoIndex: number, + blocks: PortraitClipMediaBlock[], +) => videoIndex >= 0 && videoIndex < blocks.length; + +// this converts watched time into completion and skip fractions in the 0..1 range +const calculateSkipRateMetrics = ({ + watchedDurationMs, + totalDurationMs, +}: { + watchedDurationMs: number; + totalDurationMs: number; +}) => { + const boundedWatchedDurationMs = Math.min( + Math.max(watchedDurationMs, 0), + totalDurationMs, + ); + const completionRate = Number( + (boundedWatchedDurationMs / totalDurationMs).toFixed(4), + ); + + return { + watchedDurationMs: boundedWatchedDurationMs, + completionRate, + skipRate: Number((1 - completionRate).toFixed(4)), + }; +}; + const getPlayerInstance = () => window?.embeddedMedia?.api?.players()?.bbcMediaPlayer0; @@ -143,10 +208,13 @@ export const statsNavigationCallback = async ( const currentIndex = getCurrentIndex({ e, blocks }); const newIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1; + const selectedVideo = blocks?.[newIndex]; + + if (!selectedVideo) return; const newEventTrackingData = getEventTrackingData({ eventTrackingData, - selectedVideo: blocks?.[newIndex], + selectedVideo, selectedVideoIndex: newIndex, }); @@ -168,10 +236,13 @@ export const playbackEndedCallback = async ( const currentIndex = getCurrentIndex({ blocks, player }); const newIndex = currentIndex + 1; + const selectedVideo = blocks?.[newIndex]; + + if (!selectedVideo) return; const newEventTrackingData = getEventTrackingData({ eventTrackingData, - selectedVideo: blocks?.[newIndex], + selectedVideo, selectedVideoIndex: newIndex, }); @@ -229,20 +300,217 @@ const PortraitVideoModal = ({ selectedVideoIndex, }), ); + // this sends the custom skip-rate event while reusing existing view tracking transport. + const skipRateTracker = useSwipeTracker({ + ...eventTrackingData, + componentName: SKIP_RATE_COMPONENT_NAME, + eventGroupingName: SKIP_RATE_EVENT_GROUPING_NAME, + alwaysInView: true, + }); const closeButtonRef = useRef(null); const endOfContentButtonRef = useRef(null); + // this stores timing state for the currently active video in the modal. + const activeVideoSessionRef = useRef(null); + // this avoids duplicate close tracking when multiple close paths fire. + const modalHasClosedRef = useRef(false); + + // this starts timing for a specific video index. + const startVideoSession = useCallback( + (videoIndex: number) => { + if (!shouldTrackSkipRate()) return; + + if (!isValidVideoIndex(videoIndex, blocks)) { + activeVideoSessionRef.current = null; + return; + } + + activeVideoSessionRef.current = { + index: videoIndex, + startedAt: Date.now(), + }; + }, + [blocks], + ); + + // this emits one skip-rate event for the active video session. + // watched time here is modal dwell time, not smp playhead time. + const trackSkipRateForActiveVideo = useCallback( + async ({ + exitReason, + navigationMethod, + }: { + exitReason: SkipTrackingExitReason; + navigationMethod?: string; + }) => { + if (!shouldTrackSkipRate()) return; + + const activeVideoSession = activeVideoSessionRef.current; + + if ( + !activeVideoSession || + !isValidVideoIndex(activeVideoSession.index, blocks) + ) { + return; + } + + const activeVideo = blocks[activeVideoSession.index]; + const totalDurationMs = moment + .duration(activeVideo?.model?.video?.version?.duration) + .asMilliseconds(); + + if (!Number.isFinite(totalDurationMs) || totalDurationMs <= 0) return; + + const { watchedDurationMs, completionRate, skipRate } = + calculateSkipRateMetrics({ + watchedDurationMs: Date.now() - activeVideoSession.startedAt, + totalDurationMs, + }); + + await skipRateTracker({ + ...eventTrackingData, + componentName: SKIP_RATE_COMPONENT_NAME, + eventGroupingName: SKIP_RATE_EVENT_GROUPING_NAME, + alwaysInView: true, + groupTracker: { + ...eventTrackingData.groupTracker, + type: 'portrait-video-modal', + }, + itemTracker: { + type: 'portrait-video', + text: activeVideo?.model?.video?.title, + mediaType: 'video', + position: activeVideoSession.index + 1, + duration: watchedDurationMs, + totalDuration: totalDurationMs, + completionRate, + skipRate, + navigationMethod, + exitReason, + versionId: activeVideo?.model?.video?.version?.id, + resourceId: activeVideo?.model?.video?.id, + }, + }); + }, + [blocks, eventTrackingData, skipRateTracker], + ); + + // this finalises the current video's metrics and then starts timing the next one. + const trackVideoTransition = useCallback( + async ({ + nextIndex, + exitReason, + navigationMethod, + }: { + nextIndex: number; + exitReason: SkipTrackingExitReason; + navigationMethod?: string; + }) => { + await trackSkipRateForActiveVideo({ exitReason, navigationMethod }); + startVideoSession(nextIndex); + }, + [startVideoSession, trackSkipRateForActiveVideo], + ); + + // this centralises close tracking so every close path behaves consistently. + const handleModalClose = useCallback( + (exitReason: SkipTrackingExitReason) => { + if (!modalHasClosedRef.current) { + modalHasClosedRef.current = true; + trackSkipRateForActiveVideo({ exitReason }).catch(() => undefined); + activeVideoSessionRef.current = null; + } + + onClose(); + }, + [onClose, trackSkipRateForActiveVideo], + ); + + // this keeps local timing in sync with whichever video smp actually loads. + const handlePlaylistLoaded = useCallback( + (e: SMPEvent) => { + const currentIndex = getCurrentIndex({ e, blocks }); + const activeVideoIndex = activeVideoSessionRef.current?.index; + + if (isValidVideoIndex(currentIndex, blocks)) { + if (activeVideoIndex == null) { + startVideoSession(currentIndex); + } else if (activeVideoIndex !== currentIndex) { + trackVideoTransition({ + nextIndex: currentIndex, + exitReason: 'playlist-sync', + navigationMethod: 'playlistLoaded', + }).catch(() => undefined); + } + } + + playlistLoadedCallback(e, blocks); + }, + [blocks, startVideoSession, trackVideoTransition], + ); + + // this tracks an intentional user navigation between videos. + const handleStatsNavigation = useCallback( + async (e: SMPEvent) => { + const currentIndex = getCurrentIndex({ e, blocks }); + const nextIndex = + e?.direction === 'next' ? currentIndex + 1 : currentIndex - 1; + + if ( + isValidVideoIndex(currentIndex, blocks) && + isValidVideoIndex(nextIndex, blocks) + ) { + await trackVideoTransition({ + nextIndex, + exitReason: 'navigation', + navigationMethod: e?.method ?? 'unknown', + }); + } + + await statsNavigationCallback(e, blocks, eventTrackingData, swipeTracker); + }, + [blocks, eventTrackingData, swipeTracker, trackVideoTransition], + ); + + // this tracks autoplay moving from one video to the next after the current video ends. + const handlePlaybackEnded = useCallback( + async (e: SMPEvent) => { + const player = getPlayerInstance(); + const hasEnded = Boolean(e?.ended); + const autoplayEnabled = Boolean(player?.settings?.().autoplay); + + if (hasEnded && autoplayEnabled) { + const currentIndex = getCurrentIndex({ blocks, player }); + + if (isValidVideoIndex(currentIndex, blocks)) { + await trackVideoTransition({ + nextIndex: currentIndex + 1, + exitReason: 'autoplay-end', + navigationMethod: 'autoplay', + }); + } + } + + await playbackEndedCallback(e, blocks, eventTrackingData, swipeTracker); + }, + [blocks, eventTrackingData, swipeTracker, trackVideoTransition], + ); + + // this starts the initial session when the modal mounts with a selected video. + useEffect(() => { + startVideoSession(selectedVideoIndex); + }, [selectedVideoIndex, startVideoSession]); useEffect(() => { const handleBackdropClick = (event: MouseEvent | TouchEvent) => { if (event.target === event.currentTarget) { - onClose(); + handleModalClose('backdrop'); } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - onClose(); + handleModalClose('escape'); } // - Tab/Shift+Tab loops focus between the close button and the end-of-content button if (event.key === 'Tab') { @@ -280,11 +548,19 @@ const PortraitVideoModal = ({ modal?.removeEventListener('touchstart', handleBackdropClick); modal?.removeEventListener('keydown', handleKeyDown); + // this is a fallback so we still emit if react unmounts before an explicit close path runs. + if (!modalHasClosedRef.current) { + trackSkipRateForActiveVideo({ exitReason: 'unmount' }).catch( + () => undefined, + ); + activeVideoSessionRef.current = null; + } + const player = getPlayerInstance(); // Pause any player if the modal is closed instantly if (player) player.pause(); }; - }, [onClose]); + }, [handleModalClose, trackSkipRateForActiveVideo]); return ( <> @@ -303,7 +579,7 @@ const PortraitVideoModal = ({ data-testid="close-modal-button" css={styles.closeButton} className="focusIndicatorInvert" - onClick={onClose} + onClick={() => handleModalClose('close-button')} > {navigationIcons.cross} {closeVideo} @@ -337,18 +613,11 @@ const PortraitVideoModal = ({ css={styles.mediaWrapper} blocks={[blocks?.[selectedVideoIndex]]} eventMapping={{ - playlistLoaded: e => playlistLoadedCallback(e, blocks), + playlistLoaded: handlePlaylistLoaded, pluginLoaded: pluginLoadedCallback, - fullscreenExit: onClose, - statsNavigation: e => - statsNavigationCallback( - e, - blocks, - eventTrackingData, - swipeTracker, - ), - pause: e => - playbackEndedCallback(e, blocks, eventTrackingData, swipeTracker), + fullscreenExit: () => handleModalClose('fullscreen-exit'), + statsNavigation: handleStatsNavigation, + pause: handlePlaybackEnded, }} />