/** * External dependencies */ import type { DayResults, GoalId } from '@nab/types'; type Day = DayResults[ 'date' ]; type TimeGrouping = 'daily' | 'weekly' | 'monthly'; type Weekday = number; /** * Aggregates daily results into calendar days, weeks, or months. * * Missing days do not affect the aggregation. Weekly periods use the * configured first day of the week. Each returned date identifies the first * day of its corresponding period. * * @param results Daily results to aggregate. * @param grouping Desired time grouping. * @param firstDayOfWeek First day of the week, where 0 is Sunday, 1 is * Monday, and so on. * * @return Aggregated results sorted chronologically. */ export function aggregateResultsByTimeGrouping( results: ReadonlyArray< DayResults >, grouping: TimeGrouping, firstDayOfWeek: Weekday ): ReadonlyArray< DayResults > { const buckets = new Map< Day, DayResults >(); for ( const result of results ) { const date = getPeriodStart( result.date, grouping, firstDayOfWeek ); const current = buckets.get( date ); if ( ! current ) { buckets.set( date, { date, visits: result.visits, conversions: { ...result.conversions }, values: { ...result.values }, uniqueVisits: result.uniqueVisits, uniqueConversions: { ...result.uniqueConversions, }, uniqueValues: { ...result.uniqueValues }, } ); continue; } buckets.set( date, mergeResults( current, result, date ) ); } return [ ...buckets.values() ].sort( ( a, b ) => a.date.localeCompare( b.date ) ); } function getPeriodStart( day: Day, grouping: TimeGrouping, firstDayOfWeek: Weekday ): Day { const date = parseDay( day ); switch ( grouping ) { case 'daily': break; case 'weekly': { const weekday = date.getUTCDay(); const daysSincePeriodStart = ( weekday - firstDayOfWeek + 7 ) % 7; date.setUTCDate( date.getUTCDate() - daysSincePeriodStart ); break; } case 'monthly': date.setUTCDate( 1 ); break; } return formatDay( date ); } function mergeResults( first: DayResults, second: DayResults, date: Day ): DayResults { return { date, visits: first.visits + second.visits, conversions: sumGoalValues( first.conversions, second.conversions ), values: sumGoalValues( first.values, second.values ), uniqueVisits: first.uniqueVisits + second.uniqueVisits, uniqueConversions: sumGoalValues( first.uniqueConversions, second.uniqueConversions ), uniqueValues: sumGoalValues( first.uniqueValues, second.uniqueValues ), }; } function sumGoalValues( first: Partial< Record< GoalId, number > >, second: Partial< Record< GoalId, number > > ): Partial< Record< GoalId, number > > { const result: Partial< Record< GoalId, number > > = { ...first, }; for ( const [ goalIdString, value ] of Object.entries( second ) ) { const goalId = goalIdString as GoalId; result[ goalId ] = ( result[ goalId ] ?? 0 ) + ( value ?? 0 ); } return result; } function parseDay( day: Day ): Date { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec( day ); if ( ! match ) { throw new Error( `Invalid day: ${ day }` ); } const [ , year, month, date ] = match; const parsed = new Date( Date.UTC( Number( year ), Number( month ) - 1, Number( date ) ) ); if ( formatDay( parsed ) !== day ) { throw new Error( `Invalid day: ${ day }` ); } return parsed; } function formatDay( date: Date ): Day { return date.toISOString().slice( 0, 10 ); }