__( 'Today', 'dealroot' ), 'yesterday' => __( 'Yesterday', 'dealroot' ), '7d' => __( 'Last 7 days', 'dealroot' ), '30d' => __( 'Last 30 days', 'dealroot' ), '90d' => __( 'Last 90 days', 'dealroot' ), 'this_month' => __( 'This month', 'dealroot' ), 'last_month' => __( 'Last month', 'dealroot' ), '12m' => __( 'Last 12 months', 'dealroot' ), 'all' => __( 'All time', 'dealroot' ), ); } /** * Report dimensions. * * @return array */ public static function dimensions() { return array( 'channel' => __( 'Channel', 'dealroot' ), 'source' => __( 'Source', 'dealroot' ), 'campaign' => __( 'Campaign', 'dealroot' ), 'landing' => __( 'Landing page', 'dealroot' ), 'form' => __( 'Form', 'dealroot' ), 'device' => __( 'Device', 'dealroot' ), 'browser' => __( 'Browser', 'dealroot' ), 'country' => __( 'Country', 'dealroot' ), 'city' => __( 'City', 'dealroot' ), ); } /** * Database column for a dimension and attribution model. * * @param string $dimension Dimension key. * @param string $model "first" or "last". * @return string */ public static function column( $dimension, $model ) { $last = 'last' === $model; switch ( $dimension ) { case 'source': return $last ? 'last_source' : 'source'; case 'campaign': return $last ? 'last_campaign' : 'campaign'; case 'landing': return 'landing_page'; case 'form': return 'form_name'; case 'device': case 'browser': case 'country': case 'city': return $dimension; default: return $last ? 'last_channel' : 'channel'; } } /** * UTC boundaries of a period, using the site's time zone for calendar months. * * @param string $period Period key. * @return array{0: string, 1: string} From and to in Y-m-d H:i:s UTC; empty means open. */ public static function range( $period ) { $tz = wp_timezone(); $now = new \DateTimeImmutable( 'now', $tz ); $utc = new \DateTimeZone( 'UTC' ); $start = null; $end = null; // Days chosen for a report, e.g. "2026-09-01_2026-09-30", both included. if ( preg_match( '/^(\d{4}-\d{2}-\d{2})_(\d{4}-\d{2}-\d{2})$/', (string) $period, $days ) ) { $first = \DateTimeImmutable::createFromFormat( '!Y-m-d', $days[1], $tz ); $last = \DateTimeImmutable::createFromFormat( '!Y-m-d', $days[2], $tz ); if ( $first && $last && $first <= $last ) { return array( $first->setTimezone( $utc )->format( 'Y-m-d H:i:s' ), $last->modify( '+1 day' )->setTimezone( $utc )->format( 'Y-m-d H:i:s' ), ); } } // The last days are whole days: today and the days before it, as on the charts. $today = $now->setTime( 0, 0 ); switch ( $period ) { case 'today': $start = $today; break; case 'yesterday': $start = $today->modify( '-1 day' ); $end = $today; break; case '7d': $start = $today->modify( '-6 days' ); break; case '30d': $start = $today->modify( '-29 days' ); break; case '90d': $start = $today->modify( '-89 days' ); break; case '12m': $start = $today->modify( '-12 months +1 day' ); break; case 'this_month': $start = $now->modify( 'first day of this month' )->setTime( 0, 0 ); break; case 'last_month': $start = $now->modify( 'first day of last month' )->setTime( 0, 0 ); $end = $now->modify( 'first day of this month' )->setTime( 0, 0 ); break; } return array( $start ? $start->setTimezone( $utc )->format( 'Y-m-d H:i:s' ) : '', $end ? $end->setTimezone( $utc )->format( 'Y-m-d H:i:s' ) : '', ); } /** * The same span of time right before a period, to compare with. Calendar months are * compared with the month before, the current month up to the same day. * * @param string $period Period key. * @return array{0: string, 1: string} From and to in Y-m-d H:i:s UTC; both empty for all time. */ public static function previous_range( $period ) { list( $from, $to ) = self::range( $period ); if ( '' === $from ) { return array( '', '' ); } $tz = wp_timezone(); $utc = new \DateTimeZone( 'UTC' ); $start = new \DateTimeImmutable( $from, $utc ); $end = '' !== $to ? new \DateTimeImmutable( $to, $utc ) : new \DateTimeImmutable( 'now', $utc ); switch ( $period ) { case 'last_month': $local = $start->setTimezone( $tz ); $prev_start = $local->modify( 'first day of last month' )->setTime( 0, 0 ); $prev_end = $local; break; case 'this_month': $local = $start->setTimezone( $tz ); $prev_start = $local->modify( 'first day of last month' )->setTime( 0, 0 ); $prev_end = $prev_start->modify( '+' . ( $end->getTimestamp() - $start->getTimestamp() ) . ' seconds' ); $prev_end = min( $prev_end, $local ); break; case 'today': // Yesterday up to the same time of day. $local = $start->setTimezone( $tz ); $prev_start = $local->modify( '-1 day' ); $prev_end = $prev_start->modify( '+' . ( $end->getTimestamp() - $start->getTimestamp() ) . ' seconds' ); break; case 'yesterday': $local = $start->setTimezone( $tz ); $prev_end = $local; $prev_start = $local->modify( '-1 day' ); break; case '7d': case '30d': case '90d': // As many whole days before, e.g. the 7 days before the last 7. $local = $start->setTimezone( $tz ); $prev_end = $local; $prev_start = $local->modify( '-' . (int) $period . ' days' ); break; default: $prev_end = $start; $prev_start = $start->modify( '-' . ( $end->getTimestamp() - $start->getTimestamp() ) . ' seconds' ); } return array( $prev_start->setTimezone( $utc )->format( 'Y-m-d H:i:s' ), $prev_end->setTimezone( $utc )->format( 'Y-m-d H:i:s' ), ); } /** * Boundaries for a created_at condition. Open ends become the widest DATETIME values, * so every query can use the same BETWEEN-style condition. * * @param string $from UTC start, '' for all time. * @param string $to UTC end, '' for now. * @return array{0: string, 1: string} */ public static function sql_range( $from, $to ) { return array( '' !== $from ? $from : '1000-01-01 00:00:00', '' !== $to ? $to : '9999-12-31 23:59:59', ); } /** * Boundaries for tables that count per day in the site's time zone. * * @param string $from UTC start, '' for all time. * @param string $to UTC end, '' for now. * @return array{0: string, 1: string} Dates as Y-m-d. */ public static function sql_days( $from, $to ) { return array( '' !== $from ? get_date_from_gmt( $from, 'Y-m-d' ) : '1000-01-01', '' !== $to ? get_date_from_gmt( $to, 'Y-m-d' ) : '9999-12-31', ); } /** * Leads, won deals and revenue per group. * * @param string $period Period key. * @param string $dimension Dimension key. * @param string $model Attribution model. * @return array Group key => leads, won, revenue (currency => amount), clicks (type => count). */ public static function breakdown( $period, $dimension, $model ) { global $wpdb; list( $from, $to ) = self::range( $period ); $column = self::column( $dimension, $model ); $margin = self::default_margin(); list( $start, $end ) = self::sql_range( $from, $to ); // Sources are split by channel, so the ads of a service are a row of their own, and so // are visits without a campaign, e.g. "No campaign: Organic search". $channel = in_array( $dimension, array( 'source', 'campaign' ), true ) ? self::column( 'channel', $model ) : $column; $split = 'campaign' === $dimension ? 'campaign' : 'source' === $dimension; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( "SELECT %i AS grp, %i AS chn, currency, COUNT(*) AS leads, SUM(CASE WHEN status = 'won' THEN 1 ELSE 0 END) AS won, SUM(CASE WHEN status = 'won' THEN COALESCE(value, 0) ELSE 0 END) AS revenue, SUM(CASE WHEN status = 'won' THEN COALESCE(value, 0) * COALESCE(margin, %f) ELSE 0 END) AS profit FROM %i WHERE status <> 'spam' AND created_at >= %s AND created_at < %s GROUP BY %i, %i, currency", $column, $channel, null === $margin ? 0 : $margin, Installer::table(), $start, $end, $column, $channel ) ); $result = array(); foreach ( (array) $rows as $row ) { $key = self::group_key( $dimension, self::split_key( $split, (string) $row->grp, (string) $row->chn ) ); if ( ! isset( $result[ $key ] ) ) { $result[ $key ] = self::empty_row(); } $result[ $key ]['leads'] += (int) $row->leads; $result[ $key ]['won'] += (int) $row->won; if ( (float) $row->revenue > 0 ) { $currency = '' !== $row->currency ? $row->currency : Settings::get( 'currency' ); $result[ $key ]['revenue'][ $currency ] = ( $result[ $key ]['revenue'][ $currency ] ?? 0 ) + (float) $row->revenue; if ( null !== $margin ) { $result[ $key ]['profit'][ $currency ] = round( ( $result[ $key ]['profit'][ $currency ] ?? 0 ) + (float) $row->profit, 2 ); } } } // Clicks share the attribution columns, not the form or the visitor. if ( 'form' !== $dimension && ! in_array( $dimension, self::VISITOR_DIMENSIONS, true ) ) { foreach ( Events::counts( $from, $to, $column, $split ) as $grp => $counts ) { $key = self::group_key( $dimension, (string) $grp ); if ( ! isset( $result[ $key ] ) ) { $result[ $key ] = self::empty_row(); } foreach ( $counts as $type => $count ) { $result[ $key ]['clicks'][ $type ] = ( $result[ $key ]['clicks'][ $type ] ?? 0 ) + $count; } } } foreach ( (array) Visits::counts( $from, $to, $column, $split ) as $grp => $visits ) { $key = self::group_key( $dimension, (string) $grp ); if ( ! isset( $result[ $key ] ) ) { $result[ $key ] = self::empty_row(); } $result[ $key ]['visits'] += (int) $visits; } /** * Filters the report rows, e.g. to add advertising costs. * * Each row has the keys leads, won, revenue (currency => amount), profit (currency => * amount), clicks (type => count), visits and cost (currency => amount). Added rows need * the same keys. * * @param array $result Group key => row. * @param string $dimension Dimension. * @param string $model Attribution model. * @param string $from Start of the period in UTC, '' when open. * @param string $to End of the period in UTC, '' when open. */ $result = (array) apply_filters( 'dealroot_report_breakdown', $result, $dimension, $model, $from, $to ); foreach ( $result as $key => $row ) { $result[ $key ] = array_merge( self::empty_row(), (array) $row ); } if ( 'source' === $dimension ) { $result = self::merge_spellings( $result ); } // Visits without a campaign come after the campaigns, which this report is about. $last = 'campaign' === $dimension; uksort( $result, static function ( $key_a, $key_b ) use ( $result, $last ) { $a = $result[ $key_a ]; $b = $result[ $key_b ]; $order = $last ? ( 0 === strpos( (string) $key_a, '~' ) ) <=> ( 0 === strpos( (string) $key_b, '~' ) ) : 0; if ( 0 === $order ) { $order = $b['leads'] <=> $a['leads']; } if ( 0 === $order ) { $order = ( $b['clicks']['_total'] ?? 0 ) <=> ( $a['clicks']['_total'] ?? 0 ); } if ( 0 === $order ) { $order = $b['visits'] <=> $a['visits']; } return 0 !== $order ? $order : array_sum( $b['cost'] ) <=> array_sum( $a['cost'] ); } ); return $result; } /** * Visits, leads and won deals per city and country of a period, for the map. Places * without a city are countries, e.g. when only the time zone of the browser was known. * * @param string $period Period key. * @return array[] country, city, lat, lon (null when unknown), visits, leads, won. */ public static function places( $period ) { global $wpdb; list( $from, $to ) = self::range( $period ); list( $start, $end ) = self::sql_range( $from, $to ); list( $day1, $day2 ) = self::sql_days( $from, $to ); $places = array(); // A point of 0, 0 stands for an unknown place and is left out of the average. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $visits = Visits::summed( 'places', $day1, $day2, static function ( $first, $last ) use ( $wpdb ) { return $wpdb->get_results( $wpdb->prepare( "SELECT country, city, SUM(visits) AS total, AVG(CASE WHEN lat <> 0 OR lon <> 0 THEN lat END) AS lat, AVG(CASE WHEN lat <> 0 OR lon <> 0 THEN lon END) AS lon FROM %i WHERE country <> '' AND day >= %s AND day < %s GROUP BY country, city", Installer::visits_table(), $first, $last ) ); } ); $leads = $wpdb->get_results( $wpdb->prepare( "SELECT country, city, COUNT(*) AS total, SUM(CASE WHEN status = 'won' THEN 1 ELSE 0 END) AS won, AVG(CASE WHEN lat <> 0 OR lon <> 0 THEN lat END) AS lat, AVG(CASE WHEN lat <> 0 OR lon <> 0 THEN lon END) AS lon FROM %i WHERE country <> '' AND status <> 'spam' AND created_at >= %s AND created_at < %s GROUP BY country, city", Installer::table(), $start, $end ) ); // phpcs:enable foreach ( array( 'visits' => (array) $visits, 'leads' => (array) $leads, ) as $kind => $rows ) { foreach ( $rows as $row ) { $key = $row->country . '|' . $row->city; if ( ! isset( $places[ $key ] ) ) { $places[ $key ] = array( 'country' => (string) $row->country, 'city' => (string) $row->city, 'lat' => null, 'lon' => null, 'visits' => 0, 'leads' => 0, 'won' => 0, ); } $places[ $key ][ $kind ] += (int) $row->total; if ( 'leads' === $kind ) { $places[ $key ]['won'] += (int) $row->won; } if ( null === $places[ $key ]['lat'] && null !== $row->lat ) { $places[ $key ]['lat'] = round( (float) $row->lat, 2 ); $places[ $key ]['lon'] = round( (float) $row->lon, 2 ); } } } return array_values( $places ); } /** * Rows of the spellings of one source, e.g. ig and instagram, as one row. The row keeps * the recorded spellings under "spellings". * * @param array $result Group key => row. * @return array */ private static function merge_spellings( array $result ) { $merged = array(); foreach ( $result as $key => $row ) { $name = Sources::key( (string) $key ); $merged[ $name ] = isset( $merged[ $name ] ) ? self::add_rows( $merged[ $name ], $row ) : $row; $paid = Sources::split( (string) $key ); $merged[ $name ]['spellings'][] = $paid ? $paid[0] : (string) $key; } return $merged; } /** * Sum of two report rows. * * @param array $a Row. * @param array $b Row. * @return array */ public static function add_rows( array $a, array $b ) { foreach ( array( 'leads', 'won', 'visits' ) as $key ) { $a[ $key ] += $b[ $key ]; } foreach ( $b['clicks'] as $type => $count ) { $a['clicks'][ $type ] = ( $a['clicks'][ $type ] ?? 0 ) + $count; } foreach ( array( 'revenue', 'profit', 'cost' ) as $key ) { foreach ( $b[ $key ] as $currency => $amount ) { $a[ $key ][ $currency ] = round( ( $a[ $key ][ $currency ] ?? 0 ) + $amount, 2 ); } } return $a; } /** * A report row without data. * * @return array */ public static function empty_row() { return array( 'leads' => 0, 'won' => 0, 'revenue' => array(), 'profit' => array(), 'clicks' => array(), 'visits' => 0, 'cost' => array(), ); } /** * Share of a deal that is profit when a lead has no margin of its own, e.g. 0.3. Null * when profit is not tracked. * * @return float|null */ public static function default_margin() { /** * Filters the margin used for won deals without a margin of their own, e.g. 0.3 for * 30 %. Reports show profit only when this is set. * * @param float|null $margin Margin between -1 and 1, or null. */ $margin = apply_filters( 'dealroot_default_margin', null ); return is_numeric( $margin ) ? max( -1.0, min( 1.0, (float) $margin ) ) : null; } /** * Profit of a won deal, or null when unknown. * * @param object $lead Lead. * @return float|null */ public static function profit( $lead ) { $default = self::default_margin(); if ( null === $default || 'won' !== $lead->status || null === $lead->value || '' === $lead->value ) { return null; } $margin = isset( $lead->margin ) && '' !== (string) $lead->margin ? (float) $lead->margin : $default; return round( (float) $lead->value * $margin, 2 ); } /** * The first day Dealroot recorded on this site, in its time zone: ad costs before it are * not compared with the enquiries, which were not recorded then. * * @return string Y-m-d, or "" when nothing was recorded yet. */ public static function tracking_since() { global $wpdb; static $since = null; if ( null !== $since ) { return $since; } // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $lead = (string) $wpdb->get_var( $wpdb->prepare( "SELECT MIN(created_at) FROM %i WHERE form_plugin NOT IN ('manual', 'woocommerce')", Installer::table() ) ); $visit = (string) $wpdb->get_var( $wpdb->prepare( 'SELECT MIN(day) FROM %i', Installer::visits_table() ) ); // phpcs:enable $days = array_filter( array( '' !== $lead ? get_date_from_gmt( $lead, 'Y-m-d' ) : '', $visit ) ); $since = $days ? (string) min( $days ) : ''; return $since; } /** * Visits in a period, independent of the grouping. * * @param string $period Period key. * @return int */ public static function total_visits( $period ) { list( $from, $to ) = self::range( $period ); return (int) array_sum( (array) Visits::counts( $from, $to, 'channel' ) ); } /** * When a lead was first answered: its first message sent, or the first change of its status, * whichever came first. Orders and leads added by hand have no answer to wait for. * * @param object $lead Lead. * @return int Timestamp, 0 for none. */ public static function first_answer( $lead ) { if ( in_array( (string) $lead->form_plugin, array( 'manual', 'woocommerce', 'order' ), true ) || in_array( (string) ( $lead->ai_kind ?? '' ), array( 'order', 'spam', 'job' ), true ) ) { return 0; } $times = ! empty( $lead->contacted_at ) ? array( strtotime( $lead->contacted_at . ' UTC' ) ) : array(); foreach ( Messages::for_lead( (int) $lead->id ) as $message ) { if ( 'out' === $message->direction && 'confirm' !== $message->channel ) { $times[] = strtotime( $message->created_at . ' UTC' ); break; } } return $times ? (int) min( $times ) : 0; } /** * Median time from an enquiry to the first answer, in seconds: the first message sent from the * lead, or the first change of its status, whichever came first. Orders, which a shop answers * itself, and spam are left out, and so are the automatic confirmations. * * @param string $period Period key. * @return int|null Null when no lead was answered in the period. */ public static function response_time( $period ) { global $wpdb; list( $from, $to ) = self::range( $period ); list( $start, $end ) = self::sql_range( $from, $to ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( "SELECT l.created_at, l.contacted_at, ( SELECT MIN( m.created_at ) FROM %i m WHERE m.lead_id = l.id AND m.direction = 'out' AND m.channel <> 'confirm' ) AS replied_at FROM %i l WHERE l.form_plugin NOT IN ('manual', 'woocommerce', 'order') AND l.ai_kind NOT IN ('order', 'spam', 'job') AND l.status <> 'spam' AND l.created_at >= %s AND l.created_at < %s ORDER BY l.created_at DESC LIMIT 2000", Messages::table(), Installer::table(), $start, $end ) ); $durations = array(); foreach ( (array) $rows as $row ) { $times = array_filter( array( (string) $row->contacted_at, (string) $row->replied_at ) ); if ( ! $times ) { continue; } $durations[] = max( 0, strtotime( min( $times ) . ' UTC' ) - strtotime( $row->created_at . ' UTC' ) ); } if ( ! $durations ) { return null; } sort( $durations ); $middle = intdiv( count( $durations ), 2 ); return count( $durations ) % 2 ? $durations[ $middle ] : intdiv( $durations[ $middle - 1 ] + $durations[ $middle ], 2 ); } /** * Totals across all groups. * * @param array $breakdown Output of breakdown(). * @return array leads, won, revenue, profit, clicks, visits, cost. */ public static function totals( array $breakdown ) { $totals = self::empty_row(); foreach ( $breakdown as $row ) { $totals['leads'] += $row['leads']; $totals['won'] += $row['won']; $totals['visits'] += $row['visits'] ?? 0; foreach ( $row['revenue'] as $currency => $amount ) { $totals['revenue'][ $currency ] = ( $totals['revenue'][ $currency ] ?? 0 ) + $amount; } foreach ( $row['profit'] ?? array() as $currency => $amount ) { $totals['profit'][ $currency ] = ( $totals['profit'][ $currency ] ?? 0 ) + $amount; } foreach ( $row['clicks'] as $type => $count ) { $totals['clicks'][ $type ] = ( $totals['clicks'][ $type ] ?? 0 ) + $count; } foreach ( $row['cost'] ?? array() as $currency => $amount ) { $totals['cost'][ $currency ] = ( $totals['cost'][ $currency ] ?? 0 ) + $amount; } } return $totals; } /** * Display label for a group key. * * @param string $dimension Dimension. * @param string $key Group key. * @return string */ public static function label( $dimension, $key ) { if ( 'channel' === $dimension ) { return Attribution::channel_label( $key ); } if ( 'source' === $dimension ) { return Sources::label( $key ); } if ( 'campaign' === $dimension && 0 === strpos( $key, '~' ) ) { $channel = substr( $key, 1 ); /* translators: %s: channel, e.g. Organic search */ return '' !== $channel ? sprintf( __( 'No campaign: %s', 'dealroot' ), Attribution::channel_label( $channel ) ) : __( 'No campaign', 'dealroot' ); } if ( 'device' === $dimension ) { return '' !== Devices::type_label( $key ) ? Devices::type_label( $key ) : __( '(not set)', 'dealroot' ); } if ( 'country' === $dimension ) { return Geo::country_label( $key ); } if ( 'browser' === $dimension && '' !== $key ) { return Devices::browser_label( $key ); } if ( '' === $key || '(direct)' === $key ) { return __( '(not set)', 'dealroot' ); } return $key; } /** * Key of a group that is split by channel: the bought clicks of a source, e.g. * "google|paid_search", or visits without a campaign, e.g. "~organic_search". * * @param bool|string $split True for sources, "campaign" for campaigns, false for neither. * @param string $value Value of the column. * @param string $channel Channel. * @return string */ public static function split_key( $split, $value, $channel ) { if ( 'campaign' === $split ) { return '' === $value ? '~' . $channel : $value; } return $split ? Sources::paid_key( $value, $channel ) : $value; } /** * Landing pages are grouped by path, without campaign parameters. * * @param string $dimension Dimension. * @param string $value Raw value. * @return string */ private static function group_key( $dimension, $value ) { if ( 'landing' === $dimension && '' !== $value ) { return (string) strtok( $value, '?' ); } return $value; } }