*/ public static function week( $waiting = true ) { $now = time(); $current = array( gmdate( 'Y-m-d H:i:s', $now - 7 * DAY_IN_SECONDS ), gmdate( 'Y-m-d H:i:s', $now ) ); $previous = array( gmdate( 'Y-m-d H:i:s', $now - 14 * DAY_IN_SECONDS ), $current[0] ); $this_wk = self::leads_by_channel( $current[0], $current[1] ); $last_wk = self::leads_by_channel( $previous[0], $previous[1] ); $leads = array_sum( $this_wk ); $before = array_sum( $last_wk ); $items = array(); if ( $waiting ) { $open = self::waiting(); if ( $open ) { $items[] = self::item( 'waiting', 'todo', /* translators: %s: number of leads */ sprintf( _n( '%s new lead has waited more than a day for an answer.', '%s new leads have waited more than a day for an answer.', $open, 'dealroot' ), number_format_i18n( $open ) ), admin_url( 'admin.php?page=dealroot-leads&status=new' ) ); } } if ( 0 === $leads && 0 === $before ) { return $items; } $items[] = self::leads_sentence( $leads, $before ); $won = self::won( $current[0], $current[1] ); if ( $won['count'] ) { $items[] = self::item( 'won', 'good', sprintf( /* translators: 1: number of customers, 2: amount such as €2,400 */ _n( '%1$s lead became a customer, worth %2$s.', '%1$s leads became customers, worth %2$s.', $won['count'], 'dealroot' ), number_format_i18n( $won['count'] ), Admin\Format::money_list( $won['revenue'] ) ) ); } // The channel that brought most, then the one that changed most. arsort( $this_wk ); $top = (string) key( $this_wk ); if ( $this_wk && $this_wk[ $top ] >= self::MIN_LEADS && $leads > $this_wk[ $top ] ) { $items[] = self::item( 'top', 'info', sprintf( /* translators: 1: channel such as Organic search, 2: leads of the channel, 3: all leads */ __( 'Most enquiries came from %1$s: %2$s of %3$s.', 'dealroot' ), Attribution::channel_label( $top ), number_format_i18n( $this_wk[ $top ] ), number_format_i18n( $leads ) ) ); } $asked = Ai::topics( $current[0], $current[1] ); $topic = (string) key( $asked ); if ( '' !== $topic && $asked[ $topic ]['leads'] >= self::MIN_LEADS ) { $items[] = self::item( 'topic', 'info', sprintf( /* translators: 1: topic of enquiries, 2: number of leads, 3: all leads */ __( 'Most asked about: %1$s, in %2$s of %3$s enquiries.', 'dealroot' ), $topic, number_format_i18n( $asked[ $topic ]['leads'] ), number_format_i18n( $leads ) ) ); } $change = self::biggest_change( $this_wk, $last_wk ); if ( $change ) { $items[] = self::item( 'change', $change[1] > 0 ? 'good' : 'bad', sprintf( /* translators: 1: channel such as Paid search, 2: number of leads */ $change[1] > 0 ? _n( '%1$s brought %2$s more lead than the week before.', '%1$s brought %2$s more leads than the week before.', abs( $change[1] ), 'dealroot' ) : _n( '%1$s brought %2$s lead fewer than the week before.', '%1$s brought %2$s leads fewer than the week before.', abs( $change[1] ), 'dealroot' ), Attribution::channel_label( $change[0] ), number_format_i18n( abs( $change[1] ) ) ) ); } $best = self::best_rate( $this_wk, $current[0], $current[1] ); if ( $best ) { $items[] = self::item( 'rate', 'info', sprintf( /* translators: 1: channel such as Organic search, 2: percentage, 3: number of visits */ __( 'Visitors from %1$s turn into leads most often: %2$s of %3$s visits.', 'dealroot' ), Attribution::channel_label( $best['channel'] ), number_format_i18n( $best['rate'] * 100, $best['rate'] < 0.1 ? 1 : 0 ) . '%', number_format_i18n( $best['visits'] ) ) ); } $form = self::given_up( $current[0], $current[1] ); if ( $form ) { $text = sprintf( /* translators: 1: number of people, 2: name of the form, 3: number who sent it */ __( '%1$s people started “%2$s”, but only %3$s sent it.', 'dealroot' ), number_format_i18n( $form['starts'] ), $form['name'], number_format_i18n( $form['sent'] ) ); if ( '' !== $form['field'] ) { /* translators: %s: label of a form field */ $text .= ' ' . sprintf( __( 'Most stopped at “%s”.', 'dealroot' ), $form['field'] ); } $items[] = self::item( 'form', 'bad', $text ); } $city = self::top_city( $current[0], $current[1] ); if ( $city ) { $items[] = self::item( 'city', 'info', sprintf( /* translators: 1: city, 2: number of leads */ __( 'Most leads came from %1$s (%2$s).', 'dealroot' ), $city['name'], number_format_i18n( $city['leads'] ) ) ); } /** * Filters the sentences about the week. * * @param array $items Items with key, tone (good, bad, info or todo), text and url. */ return array_slice( (array) apply_filters( 'dealroot_insights', $items ), 0, 8 ); } /** * One sentence. * * @param string $key Kind. * @param string $tone good, bad, info or todo. * @param string $text Text. * @param string $url Link, optional. * @return array */ private static function item( $key, $tone, $text, $url = '' ) { return array( 'key' => $key, 'tone' => $tone, 'text' => $text, 'url' => $url, ); } /** * The number of leads compared with the week before. * * @param int $leads This week. * @param int $before The week before. * @return array */ private static function leads_sentence( $leads, $before ) { /* translators: %s: number of leads */ $count = sprintf( _n( '%s lead in the last 7 days', '%s leads in the last 7 days', $leads, 'dealroot' ), number_format_i18n( $leads ) ); $diff = $leads - $before; if ( 0 === $before ) { return self::item( 'leads', 'good', $count . '.' ); } if ( 0 === $diff ) { return self::item( 'leads', 'info', $count . ', ' . __( 'as many as the week before.', 'dealroot' ) ); } return self::item( 'leads', $diff > 0 ? 'good' : 'bad', $count . ', ' . sprintf( /* translators: %s: number of leads */ $diff > 0 ? _n( '%s more than the week before.', '%s more than the week before.', abs( $diff ), 'dealroot' ) : _n( '%s fewer than the week before.', '%s fewer than the week before.', abs( $diff ), 'dealroot' ), number_format_i18n( abs( $diff ) ) ) ); } /** * Leads per channel of a period, without spam. * * @param string $from UTC start. * @param string $to UTC end. * @return array */ private static function leads_by_channel( $from, $to ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( "SELECT channel, COUNT(*) AS total FROM %i WHERE status <> 'spam' AND created_at >= %s AND created_at < %s GROUP BY channel", Installer::table(), $from, $to ) ); $counts = array(); foreach ( (array) $rows as $row ) { $counts[ (string) $row->channel ] = (int) $row->total; } return $counts; } /** * Deals won in a period, with their value per currency. * * @param string $from UTC start. * @param string $to UTC end. * @return array{count: int, revenue: array} */ private static function won( $from, $to ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( "SELECT currency, COUNT(*) AS total, SUM(COALESCE(value, 0)) AS revenue FROM %i WHERE status = 'won' AND won_at >= %s AND won_at < %s GROUP BY currency", Installer::table(), $from, $to ) ); $won = array( 'count' => 0, 'revenue' => array(), ); foreach ( (array) $rows as $row ) { $won['count'] += (int) $row->total; if ( (float) $row->revenue > 0 ) { $currency = '' !== (string) $row->currency ? (string) $row->currency : (string) Settings::get( 'currency' ); $won['revenue'][ $currency ] = ( $won['revenue'][ $currency ] ?? 0 ) + (float) $row->revenue; } } return $won; } /** * New leads of the last 30 days that wait more than a day for an answer. * * @return int */ private static function waiting() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE status = 'new' AND form_plugin <> 'manual' AND created_at >= %s AND created_at < %s", Installer::table(), gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS ), gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) ) ); } /** * The channel whose leads changed most, when the change is worth a sentence. * * @param array $now Leads per channel this week. * @param array $before Leads per channel the week before. * @return array{0: string, 1: int}|null Channel and change. */ private static function biggest_change( array $now, array $before ) { $best = null; foreach ( array_unique( array_merge( array_keys( $now ), array_keys( $before ) ) ) as $channel ) { $diff = ( $now[ $channel ] ?? 0 ) - ( $before[ $channel ] ?? 0 ); if ( abs( $diff ) >= 3 && ( ! $best || abs( $diff ) > abs( $best[1] ) ) ) { $best = array( (string) $channel, $diff ); } } return $best; } /** * The channel whose visitors send an enquiry most often. * * @param array $leads Leads per channel. * @param string $from UTC start. * @param string $to UTC end. * @return array{channel: string, rate: float, visits: int}|null */ private static function best_rate( array $leads, $from, $to ) { if ( ! Settings::get( 'count_visits' ) ) { return null; } $visits = (array) Visits::counts( $from, $to, 'channel' ); $best = null; $ranked = 0; foreach ( $visits as $channel => $count ) { $count = (int) $count; $sent = (int) ( $leads[ $channel ] ?? 0 ); if ( $count < self::MIN_VISITS || $sent < self::MIN_LEADS ) { continue; } ++$ranked; $rate = min( 1, $sent / $count ); if ( ! $best || $rate > $best['rate'] ) { $best = array( 'channel' => (string) $channel, 'rate' => $rate, 'visits' => $count, ); } } // With a single channel to compare there is nothing to tell. return $ranked > 1 ? $best : null; } /** * The form that most people start and then leave, when fewer than half send it. * * @param string $from UTC start. * @param string $to UTC end. * @return array{name: string, starts: int, sent: int, field: string}|null */ private static function given_up( $from, $to ) { if ( ! Settings::get( 'form_stats' ) ) { return null; } foreach ( Form_Stats::report( $from, $to, 5 ) as $name => $form ) { if ( $form['starts'] >= self::MIN_STARTS && $form['sent'] < $form['starts'] / 2 ) { return array( 'name' => (string) $name, 'starts' => $form['starts'], 'sent' => $form['sent'], 'field' => $form['field_left'] >= 2 ? $form['field'] : '', ); } } return null; } /** * The city most leads came from, when it brought at least three. * * @param string $from UTC start. * @param string $to UTC end. * @return array{name: string, leads: int}|null */ private static function top_city( $from, $to ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $row = $wpdb->get_row( $wpdb->prepare( "SELECT country, city, COUNT(*) AS total FROM %i WHERE status <> 'spam' AND city <> '' AND created_at >= %s AND created_at < %s GROUP BY country, city ORDER BY total DESC LIMIT 1", Installer::table(), $from, $to ) ); if ( ! $row || (int) $row->total < 3 ) { return null; } return array( 'name' => Geo::clean_city( (string) $row->city ), 'leads' => (int) $row->total, ); } /** * The sentences as a Telegram message. * * @param array $items Items of week(). * @param array|null $coach Advice of the AI assistant, optional. * @return string HTML subset of Telegram. */ public static function telegram( array $items, $coach = null ) { $esc = static function ( $text ) { return htmlspecialchars( (string) $text, ENT_NOQUOTES, 'UTF-8' ); }; $lines = array( /* translators: %s: site name */ '' . $esc( sprintf( __( 'Your week – %s', 'dealroot' ), wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ) ) . '', '', ); if ( ! empty( $coach['advice'] ) ) { $lines[] = $esc( $coach['advice'] ); if ( '' !== (string) ( $coach['focus'] ?? '' ) ) { $lines[] = '' . $esc( __( 'Worth doing next', 'dealroot' ) ) . ': ' . $esc( $coach['focus'] ); } $lines[] = ''; } foreach ( $items as $item ) { $lines[] = '• ' . $esc( $item['text'] ); } $lines[] = ''; $lines[] = '' . $esc( __( 'Open the overview', 'dealroot' ) ) . ''; return implode( "\n", $lines ); } }