Charts::MONEY,
'mobile' => '#74ad8f',
'tablet' => Charts::QUIET,
);
/**
* The choice of days, weeks or months above the charts over time, HTML.
*
* @var string
*/
private $steps = '';
/**
* Page output.
*/
public function render() {
$state = Report_State::current();
$period = $state['period'];
Shell::open(
'reports',
__( 'Reports', 'dealroot' ),
array(
'description' => __( 'Your enquiries, customers and revenue as charts. Hover over a chart to see the numbers.', 'dealroot' ),
'actions' => $this->period_picker( $period ) . Shell::button( __( 'PDF report', 'dealroot' ), Report_Page::url( 'all' === $period ? 'last_month' : $period ), 'secondary', array( 'target' => '_blank' ) ),
)
);
list( $start, $end ) = Chart_Data::bounds( $period );
$totals = Chart_Data::totals( $start, $end );
if ( $totals['leads'] < 1 ) {
$this->empty_state();
Shell::close();
return;
}
$days = ( strtotime( $end . ' UTC' ) - strtotime( $start . ' UTC' ) ) / DAY_IN_SECONDS;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only how the charts are grouped.
$by = isset( $_GET['by'] ) ? sanitize_key( wp_unslash( $_GET['by'] ) ) : '';
$by = in_array( $by, self::units( $days ), true ) ? $by : '';
$series = Chart_Data::series( $start, $end, $by );
$this->steps = $this->steps( $series['unit'], $days );
$channels = Chart_Data::channels( $start, $end );
$currency = $series['currency'];
$cost = array_sum( wp_list_pluck( $series['rows'], 'cost' ) );
$this->kpis( $totals, $cost, $currency, $period );
?>
week_card();
$this->month_card( $currency );
?>
0 || $cost > 0 ) {
$this->money_card( $series );
}
/**
* Fires among the charts of the reports screen, e.g. for what the ads cost and brought.
*
* @param string $start From, UTC.
* @param string $end To, UTC.
* @param string $currency Currency of the charts.
*/
do_action( 'dealroot_reports_charts', $start, $end, $currency );
$this->columns_card( $series );
?>
channel_card( $channels, 'leads', $currency );
$this->channel_card( $channels, 'revenue', $currency );
?>
flow_card( $channels, $currency );
$this->heat_card( Chart_Data::hours( $start, $end ) );
?>
speed_card( Chart_Data::speed( $start, $end ) );
$this->devices_card( Chart_Data::devices( $start, $end ) );
?>
lost_card( Chart_Data::lost( $start, $end ) ); ?>
'any',
'per_page' => 1,
)
)['total'] > 0;
?>
__( 'Enquiries', 'dealroot' ),
'value' => number_format_i18n( $totals['leads'] ),
'now' => $totals['leads'],
'before' => $before ? $before['leads'] : null,
'meta' => '',
),
array(
'label' => __( 'New customers', 'dealroot' ),
'value' => number_format_i18n( $totals['won'] ),
'now' => $totals['won'],
'before' => $before ? $before['won'] : null,
/* translators: %s: share such as 18% */
'meta' => sprintf( __( '%s of the enquiries', 'dealroot' ), Format::percent( $totals['won'], $totals['leads'] ) ),
),
array(
'label' => __( 'Revenue', 'dealroot' ),
'value' => Format::money( $totals['revenue'], $currency ),
'now' => $totals['revenue'],
'before' => $before ? $before['revenue'] : null,
/* translators: %s: amount */
'meta' => $totals['won'] > 0 ? sprintf( __( '%s per customer', 'dealroot' ), Format::money( round( $totals['revenue'] / $totals['won'] ), $currency ) ) : '',
),
);
if ( $cost > 0 ) {
$items[] = array(
'label' => __( 'Ad costs', 'dealroot' ),
'value' => Format::money( round( $cost ), $currency ),
'now' => null,
'before' => null,
/* translators: %s: amount */
'meta' => sprintf( __( '%s left after the ads', 'dealroot' ), Format::money( round( $totals['revenue'] - $cost ), $currency ) ),
);
}
?>
delta( $item['now'], $item['before'] ); ?>
0 ? 'is-up' : ( $change < 0 ? 'is-down' : 'is-flat' );
if ( $change > 0 ) {
/* translators: %s: change such as 12% */
$text = sprintf( __( '%s more than the period before', 'dealroot' ), number_format_i18n( $change ) . '%' );
} elseif ( $change < 0 ) {
/* translators: %s: change such as 12% */
$text = sprintf( __( '%s less than the period before', 'dealroot' ), number_format_i18n( abs( $change ) ) . '%' );
} else {
$text = __( 'The same as the period before', 'dealroot' );
}
?>
' . esc_html__( 'Not enough data in this period yet.', 'dealroot' ) . '';
}
?>
label.
* @param bool $lines Whether the items are lines.
* @return string HTML.
*/
public static function key( array $items, $lines = false ) {
$html = '';
foreach ( $items as $color => $label ) {
$html .= '- ' . esc_html( $label ) . '
';
}
return $html . '
';
}
/**
* The last seven days next to the seven before, whatever the period.
*/
private function week_card() {
$days = Chart_Data::recent_days();
$now = array_sum( wp_list_pluck( $days, 'now' ) );
$was = array_sum( wp_list_pluck( $days, 'before' ) );
$rows = array();
foreach ( $days as $day ) {
$rows[] = array( $day['long'], number_format_i18n( $day['now'] ), number_format_i18n( $day['before'] ) );
}
self::card(
'week',
__( 'The last 7 days', 'dealroot' ),
/* translators: 1: enquiries in the last 7 days, 2: enquiries in the 7 days before */
sprintf( __( '%1$s enquiries, against %2$s in the 7 days before. Each day stands next to the same day of the week before.', 'dealroot' ), number_format_i18n( $now ), number_format_i18n( $was ) ),
$now + $was > 0 ? Charts::pairs( $days ) : '',
Charts::table( array( __( 'Day', 'dealroot' ), __( 'Enquiries', 'dealroot' ), __( 'A week before', 'dealroot' ) ), $rows ),
self::key(
array(
Charts::MONEY => __( 'This week', 'dealroot' ),
Charts::LIGHT => __( 'A week before', 'dealroot' ),
)
)
);
}
/**
* Revenue of this month day by day, next to last month.
*
* @param string $currency Currency.
*/
private function month_card( $currency ) {
$pace = Chart_Data::month_pace( $currency );
$now = (float) end( $pace['now'] );
$today = count( $pace['now'] );
$then = (float) ( $pace['before'][ $today - 1 ] ?? 0 );
$lead = __( 'Every deal marked as won this month, by the day it was won, whenever its enquiry came. The figures at the top count only the deals of the enquiries that came in the chosen period. The gray line is last month up to the same day.', 'dealroot' );
if ( $then > 0 ) {
$change = (int) round( ( $now - $then ) / $then * 100 );
$lead .= ' ' . ( $change >= 0
/* translators: %s: change such as 12% */
? sprintf( __( 'So far %s ahead of last month.', 'dealroot' ), number_format_i18n( $change ) . '%' )
/* translators: %s: change such as 12% */
: sprintf( __( 'So far %s behind last month.', 'dealroot' ), number_format_i18n( abs( $change ) ) . '%' ) );
}
$rows = array();
foreach ( $pace['now'] as $i => $value ) {
$rows[] = array( number_format_i18n( $i + 1 ), Format::money( $value, $currency ), isset( $pace['before'][ $i ] ) ? Format::money( $pace['before'][ $i ], $currency ) : '—' );
}
self::card(
'month',
/* translators: %s: amount */
sprintf( __( 'Won this month: %s', 'dealroot' ), Format::money( $now, $currency ) ),
$lead,
$now > 0 || $then > 0 ? Charts::pace( $pace, $currency ) : '',
Charts::table( array( __( 'Day', 'dealroot' ), __( 'This month', 'dealroot' ), __( 'Last month', 'dealroot' ) ), $rows )
);
}
/**
* Revenue and ad costs over time.
*
* @param array $series Chart_Data::series().
*/
private function money_card( array $series ) {
$currency = $series['currency'];
$head = array( $this->unit_name( $series['unit'] ), __( 'Revenue', 'dealroot' ) );
$rows = array();
if ( $series['costs'] ) {
$head[] = __( 'Ad costs', 'dealroot' );
$head[] = __( 'Left after the ads', 'dealroot' );
}
foreach ( $series['rows'] as $row ) {
$cells = array( $row['long'], Format::money( $row['revenue'], $currency ) );
if ( $series['costs'] ) {
$cells[] = Format::money( $row['cost'], $currency );
$cells[] = Format::money( $row['revenue'] - $row['cost'], $currency );
}
$rows[] = $cells;
}
self::card(
'money',
$series['costs'] ? __( 'Revenue and ad costs', 'dealroot' ) : __( 'Revenue', 'dealroot' ),
$series['costs']
? __( 'Each pair of columns is one day, week or month: gray what the ads cost, green what the new customers of those days bought for. Where the green column is higher, the ads paid off. Hover over a column for the numbers.', 'dealroot' )
: __( 'What the new customers of each day, week or month bought for. Hover over a column for the numbers.', 'dealroot' ),
Charts::money( $series ),
Charts::table( $head, $rows ),
$this->steps
);
}
/**
* Enquiries and customers over time.
*
* @param array $series Chart_Data::series().
*/
private function columns_card( array $series ) {
$rows = array();
foreach ( $series['rows'] as $row ) {
$rows[] = array( $row['long'], number_format_i18n( $row['leads'] ), number_format_i18n( $row['won'] ) );
}
self::card(
'columns',
__( 'Enquiries and new customers', 'dealroot' ),
__( 'Each column is all enquiries of the day, week or month; the dark part are the ones that became customers.', 'dealroot' ),
Charts::columns( $series ),
Charts::table( array( $this->unit_name( $series['unit'] ), __( 'Enquiries', 'dealroot' ), __( 'Of them customers', 'dealroot' ) ), $rows ),
'' . self::key(
array(
Charts::LIGHT => __( 'Enquiries', 'dealroot' ),
Charts::MONEY => __( 'Became customers', 'dealroot' ),
)
) . $this->steps . ''
);
}
/**
* Days, weeks or months to choose for the charts over time. With many days the chart gets
* wider and scrolls sideways, the newest days in view.
*
* @param string $current day, week or month shown.
* @param float $days Days of the period.
* @return string HTML.
*/
private function steps( $current, $days ) {
$choices = array(
'day' => __( 'Days', 'dealroot' ),
'week' => __( 'Weeks', 'dealroot' ),
'month' => __( 'Months', 'dealroot' ),
);
$units = self::units( $days );
// One choice is no choice.
if ( count( $units ) < 2 ) {
return '';
}
$html = '';
foreach ( $units as $key ) {
$html .= $key === $current
? '' . esc_html( $choices[ $key ] ) . ''
: '' . esc_html( $choices[ $key ] ) . '';
}
return $html . '';
}
/**
* Days, weeks or months that make a chart of a period: not a year of single days, and not
* just two or three columns.
*
* @param float $days Days of the period.
* @return string[]
*/
private static function units( $days ) {
return array_values(
array_filter(
array( 'day', 'week', 'month' ),
static function ( $unit ) use ( $days ) {
return ! ( ( 'day' === $unit && $days > 400 ) || ( 'week' === $unit && $days < 21 ) || ( 'month' === $unit && $days < 75 ) );
}
)
);
}
/**
* Name of the step of the time axis, for the table.
*
* @param string $unit day, week or month.
* @return string
*/
private function unit_name( $unit ) {
return 'day' === $unit ? __( 'Day', 'dealroot' ) : ( 'week' === $unit ? __( 'Week', 'dealroot' ) : __( 'Month', 'dealroot' ) );
}
/**
* Enquiries or revenue by channel, as a ring.
*
* @param array $channels Chart_Data::channels().
* @param string $what leads or revenue.
* @param string $currency Currency.
*/
private function channel_card( array $channels, $what, $currency ) {
$money = 'revenue' === $what;
$parts = array();
$rows = array();
$total = array_sum( wp_list_pluck( $channels, $what ) );
foreach ( $channels as $row ) {
$shown = $money ? Format::money( $row['revenue'], $currency ) : number_format_i18n( $row['leads'] );
$parts[] = array(
'label' => $row['label'],
'value' => $row[ $what ],
'color' => Charts::SLOTS[ $row['slot'] ] ?? Charts::SLOTS[0],
'shown' => $shown,
);
if ( $row[ $what ] > 0 ) {
$rows[] = array( $row['label'], $shown, Format::percent( $row[ $what ], $total ) );
}
}
self::card(
$money ? 'ring-revenue' : 'ring-leads',
$money ? __( 'Revenue by channel', 'dealroot' ) : __( 'Enquiries by channel', 'dealroot' ),
$money
? __( 'Which channels brought the customers who paid. Compare it with the ring of the enquiries: a channel with a bigger share here brings the better customers.', 'dealroot' )
: __( 'How your enquiries found you: through ads, search engines, social media or by typing in your address.', 'dealroot' ),
$total > 0
? Charts::ring(
$parts,
$money ? Charts::short_money( $total, $currency ) : number_format_i18n( $total ),
$money ? __( 'Revenue', 'dealroot' ) : __( 'Enquiries', 'dealroot' )
)
: '',
Charts::table( array( __( 'Channel', 'dealroot' ), $money ? __( 'Revenue', 'dealroot' ) : __( 'Enquiries', 'dealroot' ), __( 'Share', 'dealroot' ) ), $rows )
);
}
/**
* From the channels to customers, open and lost enquiries.
*
* @param array $channels Chart_Data::channels().
* @param string $currency Currency.
*/
private function flow_card( array $channels, $currency ) {
$key = array(
Charts::MONEY => __( 'Customers', 'dealroot' ),
Charts::LIGHT => __( 'Still open', 'dealroot' ),
Charts::QUIET => __( 'Lost', 'dealroot' ),
);
$rows = array();
foreach ( $channels as $row ) {
$rows[] = array(
$row['label'],
number_format_i18n( $row['leads'] ),
number_format_i18n( $row['won'] ),
number_format_i18n( $row['open'] ),
number_format_i18n( $row['lost'] ),
Format::percent( $row['won'], $row['leads'] ),
Format::money( $row['revenue'], $currency ),
);
}
self::card(
'flow',
__( 'Which channel brings customers', 'dealroot' ),
__( 'One bar per channel, as long as its enquiries: the dark part became customers, the light part is still open, the gray part was lost. The channel that wins the most is on top.', 'dealroot' ),
Charts::outcomes( $channels, $currency ),
Charts::table(
array( __( 'Channel', 'dealroot' ), __( 'Enquiries', 'dealroot' ), __( 'Customers', 'dealroot' ), __( 'Still open', 'dealroot' ), __( 'Lost', 'dealroot' ), __( 'Became customers', 'dealroot' ), __( 'Revenue', 'dealroot' ) ),
$rows
),
self::key( $key )
);
}
/**
* When the enquiries arrive.
*
* @param array $hours Chart_Data::hours().
*/
private function heat_card( array $hours ) {
$rows = array();
$best = array();
for ( $day = 1; $day <= 7; $day++ ) {
$name = wp_date( 'l', strtotime( 'monday this week +' . ( $day - 1 ) . ' days' ) );
$cells = $hours['cells'][ $day ];
$row = array( $name, number_format_i18n( array_sum( $cells ) ) );
foreach ( array( array( 0, 6 ), array( 6, 12 ), array( 12, 18 ), array( 18, 24 ) ) as $part ) {
$row[] = number_format_i18n( array_sum( array_slice( $cells, $part[0], $part[1] - $part[0] ) ) );
}
$rows[] = $row;
foreach ( $cells as $hour => $n ) {
$best[ $name . '|' . $hour ] = $n;
}
}
arsort( $best );
$lead = __( 'Each row is a day of the week, each square one hour of that day, from midnight on the left to late evening on the right. The darker the square, the more enquiries came at that time: be easy to reach then.', 'dealroot' );
if ( $hours['total'] >= 20 ) {
list( $name, $hour ) = explode( '|', (string) key( $best ) );
/* translators: 1: day of the week, 2: hour, e.g. 10:00, 3: hour */
$lead .= ' ' . sprintf( __( 'Most came on %1$s between %2$s and %3$s.', 'dealroot' ), $name, Charts::clock( (int) $hour ), Charts::clock( (int) $hour + 1 ) );
}
self::card(
'heat',
__( 'When enquiries arrive', 'dealroot' ),
$lead,
Charts::heat( $hours ),
Charts::table( array( __( 'Day', 'dealroot' ), __( 'Enquiries', 'dealroot' ), __( 'Night', 'dealroot' ), __( 'Morning', 'dealroot' ), __( 'Afternoon', 'dealroot' ), __( 'Evening', 'dealroot' ) ), $rows )
);
}
/**
* How fast the enquiries were answered, and how many of them became customers.
*
* @param array $speed Chart_Data::speed().
*/
private function speed_card( array $speed ) {
$values = array();
$notes = array();
$rows = array();
foreach ( $speed as $bucket ) {
$values[ $bucket['label'] ] = $bucket['leads'];
if ( $bucket['leads'] > 0 ) {
/* translators: %s: share such as 32% */
$notes[ $bucket['label'] ] = sprintf( __( '%s became customers', 'dealroot' ), Format::percent( $bucket['won'], $bucket['leads'] ) );
}
$rows[] = array( $bucket['label'], number_format_i18n( $bucket['leads'] ), Format::percent( $bucket['won'], $bucket['leads'] ) );
}
self::card(
'speed',
__( 'How fast you answer', 'dealroot' ),
__( 'Enquiries by the time until your first answer, and how many of them became customers. Usually, the faster the answer, the more customers.', 'dealroot' ),
array_sum( $values ) > 0 ? Charts::bars( $values, Charts::MONEY, $notes ) : '',
Charts::table( array( __( 'First answer', 'dealroot' ), __( 'Enquiries', 'dealroot' ), __( 'Became customers', 'dealroot' ) ), $rows )
);
}
/**
* Enquiries per device.
*
* @param array $devices Chart_Data::devices().
*/
private function devices_card( array $devices ) {
$parts = array();
$rows = array();
$total = array_sum( wp_list_pluck( $devices, 'leads' ) );
// The device most enquiries came from is the darkest, the others lighter in order.
$devices = array_values( array_intersect_key( $devices, self::DEVICES ) );
usort(
$devices,
static function ( $a, $b ) {
return $b['leads'] <=> $a['leads'];
}
);
$colors = array_values( self::DEVICES );
foreach ( $devices as $i => $row ) {
$parts[] = array(
'label' => $row['label'],
'value' => $row['leads'],
'color' => $colors[ $i ],
'shown' => number_format_i18n( $row['leads'] ),
);
$rows[] = array( $row['label'], number_format_i18n( $row['leads'] ), Format::percent( $row['leads'], $total ), Format::percent( $row['won'], $row['leads'] ) );
}
self::card(
'devices',
__( 'Phone or computer', 'dealroot' ),
__( 'On which device the enquiries were sent. If most come from phones, check your forms on a phone too.', 'dealroot' ),
$parts ? Charts::ring( $parts, number_format_i18n( $total ), __( 'Enquiries', 'dealroot' ) ) : '',
Charts::table( array( __( 'Device', 'dealroot' ), __( 'Enquiries', 'dealroot' ), __( 'Share', 'dealroot' ), __( 'Became customers', 'dealroot' ) ), $rows )
);
}
/**
* Why deals were lost.
*
* @param array $lost Chart_Data::lost().
*/
private function lost_card( array $lost ) {
if ( ! $lost ) {
return;
}
$total = array_sum( $lost );
$rows = array();
foreach ( $lost as $reason => $n ) {
$rows[] = array( $reason, number_format_i18n( $n ), Format::percent( $n, $total ) );
}
self::card(
'lost',
__( 'Why deals were lost', 'dealroot' ),
__( 'The reasons you chose when you marked an enquiry as lost. The longest bar is where to start.', 'dealroot' ),
Charts::bars( $lost, Charts::COST ),
Charts::table( array( __( 'Reason', 'dealroot' ), __( 'Deals', 'dealroot' ), __( 'Share', 'dealroot' ) ), $rows )
);
}
}