PluginProbe ʕ •ᴥ•ʔ
Independent Analytics – WordPress Analytics Plugin / 2.7.0
Independent Analytics – WordPress Analytics Plugin v2.7.0
2.15.5 2.15.4 2.15.3 2.15.2 2.15.1 2.15.0 2.14.10 trunk 1.1 1.10 1.10.1 1.11 1.12 1.13 1.14 1.15 1.16 1.17 1.17.1 1.17.2 1.17.3 1.17.4 1.18 1.18.1 1.19.0 1.19.1 1.2 1.20.0 1.21.0 1.22.0 1.22.1 1.23.0 1.23.1 1.24.0 1.24.1 1.25.0 1.25.1 1.26.0 1.27.0 1.28.0 1.28.1 1.28.2 1.28.3 1.29.0 1.3 1.30.0 1.30.1 1.4 1.5 1.6 1.7 1.8 1.9 2.0.0 2.0.1 2.1.4 2.1.5 2.1.6 2.10.0 2.10.1 2.10.2 2.10.3 2.10.4 2.11.0 2.11.1 2.11.10 2.11.2 2.11.3 2.11.4 2.11.5 2.11.6 2.11.7 2.11.8 2.11.9 2.12.0 2.12.1 2.12.2 2.13.1 2.13.2 2.13.5 2.13.6 2.14.0 2.14.1 2.14.2 2.14.4 2.14.6 2.14.7 2.14.8 2.14.9 2.2.0 2.2.1 2.3.1 2.3.2 2.4.2 2.4.3 2.5.0 2.5.1 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.7.0 2.7.1 2.7.2 2.7.3 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.8.9 2.9.2 2.9.3 2.9.4 2.9.5 2.9.6 2.9.7
independent-analytics / IAWP / Statistics / Statistics.php
independent-analytics / IAWP / Statistics Last commit date
Intervals 2 years ago Campaign_Statistics.php 2 years ago City_Statistics.php 2 years ago Country_Statistics.php 2 years ago Device_Browser_Statistics.php 2 years ago Device_OS_Statistics.php 2 years ago Device_Type_Statistics.php 2 years ago Page_Statistics.php 2 years ago Referrer_Statistics.php 2 years ago Statistic.php 2 years ago Statistics.php 2 years ago
Statistics.php
296 lines
1 <?php
2
3 namespace IAWP\Statistics;
4
5 use DateInterval;
6 use DatePeriod;
7 use DateTime;
8 use IAWP\Date_Range\Date_Range;
9 use IAWP\Form_Submissions\Form;
10 use IAWP\Illuminate_Builder;
11 use IAWP\Plugin_Group;
12 use IAWP\Query;
13 use IAWP\Query_Taps;
14 use IAWP\Rows\Rows;
15 use IAWP\Statistics\Intervals\Interval;
16 use IAWP\Statistics\Intervals\Intervals;
17 use IAWP\Utils\Calculations;
18 use IAWP\WooCommerce_Order;
19 use IAWPSCOPED\Illuminate\Database\Query\Builder;
20 use IAWPSCOPED\Illuminate\Database\Query\JoinClause;
21 use IAWPSCOPED\Illuminate\Support\Collection;
22 use IAWPSCOPED\Illuminate\Support\Str;
23 use IAWPSCOPED\Proper\Timezone;
24 use Throwable;
25 /** @internal */
26 abstract class Statistics
27 {
28 protected $date_range;
29 protected $rows;
30 protected $chart_interval;
31 private $statistics;
32 private $previous_period_statistics;
33 private $statistics_grouped_by_date_interval;
34 private $unfiltered_statistics;
35 private $previous_period_unfiltered_statistic;
36 private $unfiltered_statistics_grouped_by_date_interval;
37 private $statistic_instances;
38 // The biggest flaw here is that it requires two queries for the stats (current and previous) when
39 // that could be one. I think it would also be possible to reuse the rows query and not limit
40 // by 50 and just SUM() up all the stats columns for the quick stats. Maybe that would be faster
41 // even if two queries were still used. Needs testing.
42 public function __construct(Date_Range $date_range, ?Rows $rows = null, ?Interval $chart_interval = null)
43 {
44 $this->date_range = $date_range;
45 $this->rows = $rows;
46 $this->chart_interval = $chart_interval ?? Intervals::default_for($date_range->number_of_days());
47 if (\is_null($rows)) {
48 $this->statistics = $this->query($this->date_range);
49 $this->previous_period_statistics = $this->query($this->date_range->previous_period());
50 $this->statistics_grouped_by_date_interval = $this->query($this->date_range, null, \true);
51 } else {
52 $this->statistics = $this->query($this->date_range, $rows);
53 $this->previous_period_statistics = $this->query($this->date_range->previous_period(), $rows);
54 $this->statistics_grouped_by_date_interval = $this->query($this->date_range, $rows, \true);
55 $this->unfiltered_statistics = $this->query($this->date_range);
56 $this->previous_period_unfiltered_statistic = $this->query($this->date_range->previous_period());
57 $this->unfiltered_statistics_grouped_by_date_interval = $this->query($this->date_range, null, \true);
58 }
59 $this->statistic_instances = $this->make_statistic_instances();
60 }
61 /**
62 * @return Statistic[]
63 */
64 public function get_statistics() : array
65 {
66 return $this->statistic_instances;
67 }
68 public function get_grouped_statistics()
69 {
70 // This whole thing is a bit of a mess...
71 return Collection::make($this->statistic_instances)->groupBy(function (\IAWP\Statistics\Statistic $item, int $key) {
72 return Plugin_Group::get_plugin_group($item->plugin_group())->name();
73 })->map(function (Collection $group, $plugin_group) {
74 $items = $group->map(function (\IAWP\Statistics\Statistic $item) {
75 if (!$item->is_group_plugin_enabled()) {
76 return null;
77 }
78 return ['id' => $item->id(), 'name' => $item->name()];
79 })->filter();
80 if ($items->isEmpty()) {
81 return null;
82 }
83 return ['name' => $plugin_group, 'items' => $items->toArray()];
84 })->filter()->values()->toArray();
85 }
86 public function get_statistic(string $statistic_id) : ?\IAWP\Statistics\Statistic
87 {
88 foreach ($this->statistic_instances as $statistic_instance) {
89 if ($statistic_instance->id() == $statistic_id) {
90 return $statistic_instance;
91 }
92 }
93 return null;
94 }
95 public function has_filters() : bool
96 {
97 return !\is_null($this->rows);
98 }
99 public function chart_interval() : Interval
100 {
101 return $this->chart_interval;
102 }
103 /**
104 * I'm sure there's more we could do here. If you get a result back where there isn't a full
105 * page of results or where you're not paginating, then you can just count up the rows...
106 *
107 * @return int|null
108 */
109 public function total_number_of_rows() : ?int
110 {
111 $sessions_table = Query::get_table_name(Query::SESSIONS);
112 $views_table = Query::get_table_name(Query::VIEWS);
113 $column = $this->total_table_rows_column() ?? $this->required_column();
114 $query = Illuminate_Builder::get_builder()->selectRaw("COUNT(DISTINCT {$column}) AS total_table_rows")->from("{$sessions_table} AS sessions")->join("{$views_table} AS views", function (JoinClause $join) {
115 $join->on('sessions.session_id', '=', 'views.session_id');
116 })->tap(Query_Taps::tap_authored_content_check(\true))->when(!\is_null($this->rows), function (Builder $query) {
117 $this->rows->attach_filters($query);
118 })->whereBetween('sessions.created_at', [$this->date_range->iso_start(), $this->date_range->iso_end()])->whereBetween('views.viewed_at', [$this->date_range->iso_start(), $this->date_range->iso_end()]);
119 return $query->value('total_table_rows');
120 }
121 /**
122 * Define which id column to use to count up the total table rows. This is only required
123 * for classes that don't have a required column and don't override required_column
124 *
125 * @return string|null
126 */
127 protected function total_table_rows_column() : ?string
128 {
129 return null;
130 }
131 /**
132 * Statistics can require that a column exists in order to be included. As an example, geos
133 * requires visitors.country_code and campaigns requires sessions.campaign_id
134 *
135 * @return string|null
136 */
137 protected function required_column() : ?string
138 {
139 return null;
140 }
141 /**
142 * @return Statistic[]
143 */
144 private function make_statistic_instances() : array
145 {
146 $statistics = [$this->make_statistic(['id' => 'visitors', 'name' => \__('Visitors', 'independent-analytics'), 'plugin_group' => 'general', 'is_visible_in_dashboard_widget' => \true]), $this->make_statistic(['id' => 'views', 'name' => \__('Views', 'independent-analytics'), 'plugin_group' => 'general', 'is_visible_in_dashboard_widget' => \true]), $this->make_statistic(['id' => 'sessions', 'name' => \__('Sessions', 'independent-analytics'), 'plugin_group' => 'general']), $this->make_statistic(['id' => 'average_session_duration', 'name' => \__('Average Session Duration', 'independent-analytics'), 'plugin_group' => 'general', 'format' => 'time']), $this->make_statistic(['id' => 'bounce_rate', 'name' => \__('Bounce Rate', 'independent-analytics'), 'plugin_group' => 'general', 'format' => 'percent', 'is_growth_good' => \false, 'compute' => function (object $statistics) {
147 return Calculations::percentage($statistics->bounces, $statistics->sessions);
148 }]), $this->make_statistic(['id' => 'views_per_session', 'name' => \__('Views Per Session', 'independent-analytics'), 'plugin_group' => 'general', 'format' => 'decimal', 'compute' => function (object $statistics) {
149 return Calculations::divide($statistics->total_views, $statistics->sessions, 2);
150 }]), $this->make_statistic(['id' => 'wc_orders', 'name' => \__('Orders', 'independent-analytics'), 'plugin_group' => 'woocommerce', 'icon' => 'woocommerce']), $this->make_statistic(['id' => 'wc_gross_sales', 'name' => \__('Gross Sales', 'independent-analytics'), 'plugin_group' => 'woocommerce', 'icon' => 'woocommerce', 'format' => 'rounded-currency']), $this->make_statistic(['id' => 'wc_refunds', 'name' => \__('Refunds', 'independent-analytics'), 'plugin_group' => 'woocommerce', 'icon' => 'woocommerce']), $this->make_statistic(['id' => 'wc_refunded_amount', 'name' => \__('Refunded Amount', 'independent-analytics'), 'plugin_group' => 'woocommerce', 'icon' => 'woocommerce', 'format' => 'rounded-currency']), $this->make_statistic(['id' => 'wc_net_sales', 'name' => \__('Net Sales', 'independent-analytics'), 'plugin_group' => 'woocommerce', 'icon' => 'woocommerce', 'format' => 'rounded-currency']), $this->make_statistic(['id' => 'wc_conversion_rate', 'name' => \__('Conversion Rate', 'independent-analytics'), 'plugin_group' => 'woocommerce', 'icon' => 'woocommerce', 'format' => 'percent']), $this->make_statistic(['id' => 'wc_earnings_per_visitor', 'name' => \__('Earnings Per Visitor', 'independent-analytics'), 'plugin_group' => 'woocommerce', 'icon' => 'woocommerce', 'format' => 'currency']), $this->make_statistic(['id' => 'wc_average_order_volume', 'name' => \__('Average Order Volume', 'independent-analytics'), 'plugin_group' => 'woocommerce', 'icon' => 'woocommerce', 'format' => 'rounded-currency']), $this->make_statistic(['id' => 'form_submissions', 'name' => \__('Form Submissions', 'independent-analytics'), 'plugin_group' => 'forms']), $this->make_statistic(['id' => 'form_conversion_rate', 'name' => \__('Form Conversion Rate', 'independent-analytics'), 'plugin_group' => 'forms', 'format' => 'percent', 'compute' => function (object $statistics) {
151 return Calculations::percentage($statistics->form_submissions, $statistics->visitors, 2);
152 }])];
153 foreach (Form::get_forms() as $form) {
154 if (!$form->is_plugin_active()) {
155 continue;
156 }
157 $statistics[] = $this->make_statistic(['id' => 'form_submissions_for_' . $form->id(), 'name' => \sprintf(\_x('%s Submissions', 'Title of the contact form', 'independent-analytics'), $form->title()), 'plugin_group' => 'forms', 'is_subgroup_plugin_active' => $form->is_plugin_active(), 'plugin_group_header' => $form->plugin_name(), 'icon' => $form->icon()]);
158 $statistics[] = $this->make_statistic(['id' => 'form_conversion_rate_for_' . $form->id(), 'name' => \sprintf(\_x('%s Conversion Rate', 'Title of the contact form', 'independent-analytics'), $form->title()), 'plugin_group' => 'forms', 'is_subgroup_plugin_active' => $form->is_plugin_active(), 'plugin_group_header' => $form->plugin_name(), 'icon' => $form->icon(), 'format' => 'percent', 'compute' => function (object $statistics) use($form) {
159 $form_submission_id = 'form_submissions_for_' . $form->id();
160 return Calculations::percentage($statistics->{$form_submission_id}, $statistics->visitors, 2);
161 }]);
162 }
163 return $statistics;
164 }
165 private function make_statistic(array $attributes) : \IAWP\Statistics\Statistic
166 {
167 $statistic_id = $attributes['id'];
168 if (!\array_key_exists('compute', $attributes)) {
169 $attributes['compute'] = function ($statistics, $statistic_id) {
170 return $statistics->{$statistic_id};
171 };
172 }
173 $attributes['statistic'] = $attributes['compute']($this->statistics, $statistic_id);
174 $attributes['previous_period_statistic'] = $attributes['compute']($this->previous_period_statistics, $statistic_id);
175 $attributes['statistic_over_time'] = $this->fill_in_partial_day_range($this->statistics_grouped_by_date_interval, $attributes);
176 $attributes['unfiltered_statistic'] = $this->has_filters() ? $attributes['compute']($this->unfiltered_statistics, $statistic_id) : null;
177 return new \IAWP\Statistics\Statistic($attributes);
178 }
179 private function query(Date_Range $range, ?Rows $rows = null, bool $is_grouped_by_date_interval = \false)
180 {
181 $utc_offset = Timezone::utc_offset();
182 $site_offset = Timezone::site_offset();
183 $sessions_table = Query::get_table_name(Query::SESSIONS);
184 $views_table = Query::get_table_name(Query::VIEWS);
185 $wc_orders_table = Query::get_table_name(Query::WC_ORDERS);
186 $form_submissions_table = Query::get_table_name(Query::FORM_SUBMISSIONS);
187 $form_submissions_query = Illuminate_Builder::get_builder()->select(['form_id', 'session_id'])->selectRaw('COUNT(*) AS form_submissions')->from($form_submissions_table, 'form_submissions')->whereBetween('created_at', [$range->iso_start(), $range->iso_end()])->groupBy(['form_id', 'session_id']);
188 $session_statistics = Illuminate_Builder::get_builder();
189 $session_statistics->select('sessions.session_id')->selectRaw('COUNT(DISTINCT views.id) AS views')->selectRaw('COUNT(DISTINCT wc_orders.order_id) AS orders')->selectRaw('IFNULL(CAST(SUM(wc_orders.total) AS DECIMAL(10, 2)), 0) AS gross_sales')->selectRaw('IFNULL(CAST(SUM(wc_orders.total_refunded) AS DECIMAL(10, 2)), 0) AS total_refunded')->selectRaw('IFNULL(CAST(SUM(wc_orders.total_refunds) AS UNSIGNED), 0) AS total_refunds')->selectRaw('IFNULL(CAST(SUM(wc_orders.total - wc_orders.total_refunded) AS DECIMAL(10, 2)), 0) AS net_sales')->from("{$sessions_table} AS sessions")->join("{$views_table} AS views", function (JoinClause $join) {
190 $join->on('sessions.session_id', '=', 'views.session_id');
191 })->leftJoin("{$wc_orders_table} AS wc_orders", function (JoinClause $join) {
192 $join->on('views.id', '=', 'wc_orders.initial_view_id')->whereIn('wc_orders.status', WooCommerce_Order::tracked_order_statuses());
193 })->tap(Query_Taps::tap_authored_content_check(\true))->when(!\is_null($rows), function (Builder $query) use($rows) {
194 $rows->attach_filters($query);
195 })->whereBetween('sessions.created_at', [$range->iso_start(), $range->iso_end()])->whereBetween('views.viewed_at', [$range->iso_start(), $range->iso_end()])->groupBy('sessions.session_id')->when(!\is_null($this->required_column()), function (Builder $query) {
196 $query->whereNotNull($this->required_column());
197 });
198 $statistics = Illuminate_Builder::get_builder();
199 $statistics->selectRaw('IFNULL(CAST(SUM(sessions.total_views) AS UNSIGNED), 0) AS total_views')->selectRaw('IFNULL(CAST(SUM(session_statistics.views) AS UNSIGNED), 0) AS views')->selectRaw('COUNT(DISTINCT sessions.visitor_id) AS visitors')->selectRaw('COUNT(DISTINCT sessions.session_id) AS sessions')->selectRaw('IFNULL(CAST(AVG(TIMESTAMPDIFF(SECOND, sessions.created_at, sessions.ended_at)) AS UNSIGNED), 0) AS average_session_duration')->selectRaw('COUNT(DISTINCT IF(sessions.final_view_id IS NULL, sessions.session_id, NULL)) AS bounces')->selectRaw('IFNULL(CAST(SUM(session_statistics.orders) AS UNSIGNED), 0) AS wc_orders')->selectRaw('IFNULL(CAST(SUM(session_statistics.gross_sales) AS DECIMAL(10, 2)), 0) AS wc_gross_sales')->selectRaw('IFNULL(CAST(SUM(session_statistics.total_refunds) AS UNSIGNED), 0) AS wc_refunds')->selectRaw('IFNULL(CAST(SUM(session_statistics.total_refunded) AS DECIMAL(10, 2)), 0) AS wc_refunded_amount')->selectRaw('IFNULL(CAST(SUM(session_statistics.net_sales) AS DECIMAL(10, 2)), 0) AS wc_net_sales')->selectRaw('IFNULL(SUM(form_submissions.form_submissions), 0) AS form_submissions')->tap(function (Builder $query) {
200 foreach (Form::get_forms() as $form) {
201 $query->selectRaw('IFNULL(SUM(IF(form_submissions.form_id = ?, form_submissions.form_submissions, 0)), 0) AS ' . $form->submissions_column(), [$form->id()]);
202 }
203 })->from("{$sessions_table} AS sessions")->joinSub($session_statistics, 'session_statistics', function (JoinClause $join) {
204 $join->on('sessions.session_id', '=', 'session_statistics.session_id');
205 })->leftJoinSub($form_submissions_query, 'form_submissions', 'sessions.session_id', '=', 'form_submissions.session_id')->whereBetween('sessions.created_at', [$range->iso_start(), $range->iso_end()])->when($is_grouped_by_date_interval, function (Builder $query) use($utc_offset, $site_offset) {
206 if ($this->chart_interval->id() === 'daily') {
207 $query->selectRaw("DATE(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}')) AS date");
208 } elseif ($this->chart_interval->id() === 'monthly') {
209 $query->selectRaw("DATE_FORMAT(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}'), '%Y-%m-01 00:00:00') AS date");
210 } elseif ($this->chart_interval->id() === 'weekly') {
211 $day_of_week = \IAWPSCOPED\iawp()->get_option('iawp_dow', 0) + 1;
212 $query->selectRaw("\n IF (\n DAYOFWEEK(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}')) - {$day_of_week} < 0,\n DATE_FORMAT(SUBDATE(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}'), DAYOFWEEK(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}')) - {$day_of_week} + 7), '%Y-%m-%d 00:00:00'),\n DATE_FORMAT(SUBDATE(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}'), DAYOFWEEK(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}')) - {$day_of_week}), '%Y-%m-%d 00:00:00')\n ) AS date\n ");
213 } else {
214 $query->selectRaw("DATE_FORMAT(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}'), '%Y-%m-%d %H:00:00') AS date");
215 }
216 $query->groupByRaw("date");
217 });
218 $outer_query = Illuminate_Builder::get_builder()->selectRaw('statistics.*')->selectRaw('IF(statistics.visitors = 0, 0, (statistics.wc_orders / statistics.visitors) * 100) AS wc_conversion_rate')->selectRaw('IF(statistics.visitors = 0, 0, (statistics.wc_gross_sales - statistics.wc_refunded_amount) / visitors) AS wc_earnings_per_visitor')->selectRaw('IF(statistics.wc_orders = 0, 0, ROUND(CAST(statistics.wc_gross_sales / statistics.wc_orders AS DECIMAL(10, 2)))) AS wc_average_order_volume')->fromSub($statistics, 'statistics');
219 $results = \array_map(function (object $statistic) : object {
220 return $this->clean_up_raw_statistic_row($statistic);
221 }, $outer_query->get()->all());
222 if (!$is_grouped_by_date_interval) {
223 return $results[0];
224 }
225 return $results;
226 }
227 private function clean_up_raw_statistic_row(object $statistic) : object
228 {
229 $statistic->wc_gross_sales = \floatval($statistic->wc_gross_sales);
230 $statistic->wc_refunded_amount = \floatval($statistic->wc_refunded_amount);
231 $statistic->wc_net_sales = \floatval($statistic->wc_net_sales);
232 foreach ($statistic as $key => $value) {
233 if (Str::startsWith($key, 'form_submissions')) {
234 $statistic->{$key} = \intval($value);
235 }
236 }
237 return $statistic;
238 }
239 /**
240 * @param array $partial_day_range
241 * @param array $attributes
242 *
243 * @return array
244 */
245 private function fill_in_partial_day_range(array $partial_day_range, array $attributes) : array
246 {
247 $original_start = (clone $this->date_range->start())->setTimezone(Timezone::site_timezone());
248 $start = $this->chart_interval->calculate_start_of_interval_for($original_start);
249 $original_end = (clone $this->date_range->end())->setTimezone(Timezone::site_timezone());
250 $end = $this->chart_interval->calculate_start_of_interval_for($original_end);
251 $end->add(new DateInterval('PT1S'));
252 $date_range = new DatePeriod($start, $this->chart_interval->date_interval(), $end);
253 $filled_in_data = [];
254 foreach ($date_range as $date) {
255 // There is no 00:00:00 on 2024-03-31 as that's when Beirut switches off DST
256 if (Timezone::site_timezone()->getName() === 'Asia/Beirut' && $date->format('H:i:s') === "01:00:00") {
257 $date->setTime(0, 0, 0);
258 }
259 $stat = $this->get_statistic_for_date($partial_day_range, $date, $attributes);
260 $filled_in_data[] = [$date, $stat];
261 }
262 return $filled_in_data;
263 }
264 /**
265 * @param array $partial_day_range
266 * @param DateTime $datetime_to_match
267 * @param array $attributes
268 *
269 * @return float|int
270 */
271 private function get_statistic_for_date(array $partial_day_range, DateTime $datetime_to_match, array $attributes)
272 {
273 foreach ($partial_day_range as $day) {
274 $date = $day->date;
275 $value = $attributes['compute']($day, $attributes['id']);
276 try {
277 $datetime = new DateTime($date, Timezone::site_timezone());
278 } catch (Throwable $e) {
279 return 0;
280 }
281 // Intentionally using non-strict equality to see if two distinct DateTime objects represent the same time
282 if ($datetime == $datetime_to_match) {
283 if (\is_string($value)) {
284 if (\strpos($value, '.') !== \false) {
285 return \floatval($value);
286 } else {
287 return \intval($value);
288 }
289 }
290 return $value;
291 }
292 }
293 return 0;
294 }
295 }
296