/** * External dependencies */ import type { Dict, ExperimentId, SegmentationRule } from '@nab/types'; /** * Internal dependencies */ import { isValidSegmentationRule } from './segmentation-rules'; import { updateFirstVisit, getSegmentationSettings, setSegmentationSettings, type SegmentationSettings, } from './segmentation-settings'; import { getExperimentsWithPageViews } from '../tracking'; import type { ExperimentSummary, HeatmapSummary, Settings } from '../../types'; import { getApiUrl } from '../helpers'; export async function updateSegmentationSettings( settings: Settings ): Promise< void > { const allExperiments = settings.experiments; const allHeatmaps = settings.heatmaps; const relevantExperiments = allExperiments.filter( ( e ) => e.active || 'site' === e.segmentEvaluation ); if ( isExtraSegmentationNeeded( allExperiments, allHeatmaps ) && isExtraSegmentationOutdated() ) { if ( isExtraSegmentationNeededNow( relevantExperiments, allHeatmaps ) ) { await updateExtraSegmentation( settings ); } else { setTimeout( () => void updateExtraSegmentation( settings ), 0 ); } } updateFirstVisit(); updateActiveSegments( relevantExperiments ); } export function areSegmentsValid( settings: Settings ): boolean { const { experiments } = settings; const activeExperimentIds = experiments .filter( ( e ) => !! e.active ) .map( ( e ) => e.id ); if ( ! activeExperimentIds.length ) { return true; } return settings.segmentMatching === 'all' ? activeExperimentIds.every( doesExperimentHaveValidSegments ) : activeExperimentIds.some( doesExperimentHaveValidSegments ); } export function doesExperimentHaveValidSegments( id: ExperimentId ): boolean { const activeSegments = getAllActiveSegments(); return ! isEmptyArray( activeSegments[ id ] ); } const isHeatmapActive: Partial< Record< ExperimentId, boolean > > = {}; export function areHeatmapConditionsValid( heatmap: HeatmapSummary ): boolean { if ( undefined === isHeatmapActive[ heatmap.id ] ) { isHeatmapActive[ heatmap.id ] = heatmap.participation.every( isValidSegmentationRule ); } return !! isHeatmapActive[ heatmap.id ]; } export function getExtraSegmentationInfo(): Partial< SegmentationSettings[ 'extra' ] > { const segmentationSettings = getSegmentationSettings(); return segmentationSettings.extra || {}; } const RETURNING_VISITOR_MINUTES_THRESHOLD = 30; export function isReturningVisitor(): boolean { const segmentationSettings = getSegmentationSettings(); const minutesSinceFirstVisit = segmentationSettings.firstVisit ? gapInMinutes( Date.now(), segmentationSettings.firstVisit ) : 0; return minutesSinceFirstVisit >= RETURNING_VISITOR_MINUTES_THRESHOLD; } export function getAllActiveSegments(): Partial< Record< ExperimentId, ReadonlyArray< number > > > { const segmentationSettings = getSegmentationSettings(); const activeSegments = segmentationSettings.activeSegments || {}; return activeSegments; } export function getActiveSegments( experiment: ExperimentSummary ): ReadonlyArray< number > { if ( isActiveSegmentImplicit( experiment ) ) { return [ 0 ]; } const activeSegments = getAllActiveSegments(); return activeSegments[ experiment.id ] || []; } export function cleanOldSegments( experiments: ReadonlyArray< ExperimentId > ): void { const segmentationSettings = getSegmentationSettings(); const activeSegments = segmentationSettings.activeSegments || {}; const viewedExperimentIds = Object.keys( getExperimentsWithPageViews() ) .map( ( id ) => Number.parseInt( id ) ) .filter( ( id ) => ! isNaN( id ) && 0 < id ); // Remove experiments that do not exist or didn't have views. const newActiveSegments = Object.keys( activeSegments ) .map( ( experimentId ) => Number.parseInt( experimentId ) ) .filter( ( experimentId ): experimentId is ExperimentId => ! isNaN( experimentId ) && 0 < experimentId ) .reduce( ( result, experimentId ) => { if ( experiments.includes( experimentId ) || viewedExperimentIds.includes( experimentId ) ) { result[ experimentId ] = activeSegments[ experimentId ] ?? []; } return result; }, {} as Partial< Record< ExperimentId, ReadonlyArray< number > > > ); setSegmentationSettings( { ...segmentationSettings, activeSegments: newActiveSegments, } ); } // ======= // HELPERS // ======= function isExtraSegmentationOutdated() { const info = getExtraSegmentationInfo(); if ( ! info.lastUpdate ) { return true; } const lastUpdate = info.lastUpdate; const now = new Date().getTime(); const lastUpdateDate = new Date( lastUpdate ).getTime(); const diffTime = Math.abs( now - lastUpdateDate ); const diffDays = Math.ceil( diffTime / ( 1000 * 60 * 60 * 24 ) ); return isNaN( diffDays ) || diffDays > 7; // Update segmentation data every week. } const isExtraSegmentationNeeded = ( exps: Settings[ 'experiments' ], heatmaps: Settings[ 'heatmaps' ] ): boolean => !! getIdsThatNeedExtraSegmentation( exps, heatmaps ).length; function isExtraSegmentationNeededNow( exps: Settings[ 'experiments' ], heatmaps: Settings[ 'heatmaps' ] ): boolean { const idsWithExtraSegmentationNeeds = getIdsThatNeedExtraSegmentation( exps, heatmaps ); const settings = getSegmentationSettings(); return idsWithExtraSegmentationNeeds.some( ( id ) => ! settings.activeSegments[ id ] ); } const getIdsThatNeedExtraSegmentation = ( exps: Settings[ 'experiments' ], heatmaps: Settings[ 'heatmaps' ] ): ReadonlyArray< ExperimentId > => [ ...exps .filter( ( e ) => e.segments.some( ( s ) => s.segmentationRules.some( isExtraSegmentationRule ) ) ) .map( ( e ) => e.id ), ...heatmaps .filter( ( h ) => h.participation.some( isExtraSegmentationRule ) ) .map( ( h ) => h.id ), ]; const isExtraSegmentationRule = ( r: SegmentationRule ) => r.type === 'nab/browser' || r.type === 'nab/device-type' || r.type === 'nab/ip-address' || r.type === 'nab/location' || r.type === 'nab/operating-system'; async function updateExtraSegmentation( settings: Settings ) { try { const url = getApiUrl( settings.api, '/ipc', { siteId: settings.site, } ); const res = await window.fetch( url ); const data: string = await res.text(); const jsonData = JSON.parse( data ) as Dict; if ( ! hasExtraSegmentation( jsonData ) ) { return; } setSegmentationSettings( { ...getSegmentationSettings(), extra: { browser: jsonData.browser ?? 'Unknown', device: jsonData.device ?? 'desktop', ipAddress: jsonData.ip, location: jsonData.location, os: jsonData.os ?? 'Unknown', lastUpdate: new Date().toISOString(), }, } ); } catch ( _ ) {} } function updateActiveSegments( experiments: Settings[ 'experiments' ] ) { const segmentationSettings = getSegmentationSettings(); const previousActiveSegments = segmentationSettings.activeSegments || {}; const activeSegments = experiments.reduce( ( r, e ) => isActiveSegmentImplicit( e ) ? r : { ...r, [ e.id ]: calculateActiveSegments( e ) }, {} as SegmentationSettings[ 'activeSegments' ] ); setSegmentationSettings( { ...segmentationSettings, activeSegments: { ...activeSegments, ...previousActiveSegments, }, } ); } function calculateActiveSegments( experiment: ExperimentSummary ): ReadonlyArray< number > { const activeSegments = experiment.segments .map( ( { segmentationRules }, index ) => segmentationRules.every( isValidSegmentationRule ) ? index + 1 : 0 ) .filter( ( segmentIndex ) => 0 < segmentIndex ); return activeSegments.length ? [ 0, ...activeSegments ] : []; } function gapInMinutes( a: number, b: number ): number { return Math.abs( a - b ) / 60_000; } const isEmptyArray = ( arr?: unknown ): arr is unknown[] => !! arr && Array.isArray( arr ) && ! arr.length; const hasExtraSegmentation = ( x: Dict ): x is { readonly browser?: string; readonly device?: string; readonly ip: string; readonly location: string; readonly os?: string; } => !! x.ip && !! x.location; const isActiveSegmentImplicit = ( e: ExperimentSummary ): boolean => ! e.segments.length;