Ver Fonte

Analytics: fix goal and funnel hint examples, bots chart days and tab rows (#5857)

* Analytics: fix hint examples, bots chart days and tab wrap

Goal and funnel hints suggested added@bx_posts and /blogs/*, which
never match: the module records content_add, and the site records post
views as /page/view-post?id=N. Both hints in en and ru now show
content_add@bx_posts and /page/view-post*. A new integration test sends
site alerts through onAlert and checks that every action example in
both languages is one the module records and that, used as a goal or
funnel step, it counts the visit.

The Bots and agents chart stepped from the local start of the period,
while the table reads whole UTC days. East of UTC it dropped the last
UTC day, so today's bot hits were missing and the chart total was lower
than the table's. BxAnalyticsDb::getBotDays() now gives both the same
UTC days, and the chart walks every one of them. The new test covers
UTC, Sydney and Los Angeles, across a clock change and for "today".

The tab rows of the top lists wrapped onto two lines in narrow cards.
They now stay on one line and scroll sideways with a hidden scrollbar,
like the main menu bar; where scroll-driven animations work, the cut-off
end fades out. The focus ring sits inside the tab so the row does not
clip it.

No database migration. The two hint strings are language updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: page example for every client, wrap tabs with a mouse, show the selected tab

- The goal and funnel hints showed /page/view-post*, which only matches
  the classic site's permalinks; NEO on the web and the apps record the
  post page as /view-post?id=5. The example is now *view-post*, which
  matches it on every client (and on the classic site without
  permalinks). The hint test now sends the post page from core-web
  (both URL forms), next-web, rn-ios and rn-android.
- Tab rows wrap again where the main pointer is a mouse, which can't
  swipe a row and has no scrollbar to drag; they scroll sideways only
  on touch screens.
- In a scrolling row the script now scrolls the selected tab clear of
  the faded ends after the page loads and after every block reload,
  and does the same for a focused tab (focus() leaves a tab that is
  only cut off where it is).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko há 1 semana atrás
pai
commit
a08aa436f3

+ 21 - 4
modules/boonex/analytics/classes/BxAnalyticsDb.php

@@ -1306,14 +1306,31 @@ class BxAnalyticsDb extends BxBaseModGeneralDb
     }
 
     /**
-     * Bot rows are per UTC day: the period's days, its platform (the pages table has none)
-     * and the categories.
+     * Bot rows are per UTC day (the collector's gmdate), so a period in another timezone
+     * reads every UTC day it touches: from the UTC day of its first second to the UTC day
+     * of its last one. East of UTC that starts a day before the local first day, west of
+     * UTC it ends a day after the local last one. Reports chart these same days.
+     */
+    public function getBotDays($aFilter)
+    {
+        $iFrom = (int)$aFilter['from'];
+
+        return [
+            'from' => gmdate('Y-m-d', $iFrom),
+            'to' => gmdate('Y-m-d', max($iFrom, (int)$aFilter['to'] - 1)),
+        ];
+    }
+
+    /**
+     * Bot rows are per UTC day: the period's UTC days (getBotDays), its platform (the pages
+     * table has none) and the categories.
      */
     protected function _getBotsWhere($aFilter, $aCategories, &$aBindings, $bPlatform = true)
     {
+        $aDays = $this->getBotDays($aFilter);
         $aSql = ['`day` >= :day_from', '`day` <= :day_to'];
-        $aBindings['day_from'] = gmdate('Y-m-d', (int)$aFilter['from']);
-        $aBindings['day_to'] = gmdate('Y-m-d', max((int)$aFilter['from'], (int)$aFilter['to'] - 1));
+        $aBindings['day_from'] = $aDays['from'];
+        $aBindings['day_to'] = $aDays['to'];
 
         if($bPlatform && $aFilter['platform'] !== '') {
             $aSql[] = '`platform` = :platform';

+ 7 - 4
modules/boonex/analytics/classes/BxAnalyticsReports.php

@@ -843,11 +843,14 @@ class BxAnalyticsReports
             ['title' => _t('_bx_analytics_metric_bot_share'), 'value' => $iAll + (int)$aHumans['pageviews'] ? round(100 * $iAll / ($iAll + (int)$aHumans['pageviews']), 1) : 0, 'type' => 'percent'],
         ];
 
-        // hits per day and category, every day of the period
+        // hits per day and category: rows are per UTC day, so the chart has every UTC day the
+        // query reads (the same days as the table) and labels them in UTC. Stepping from the
+        // local start instead dropped the last UTC day east of UTC, and with it today's hits.
+        $aBotDays = $this->_oDb->getBotDays($aFilter);
+        $iDayTo = strtotime($aBotDays['to'] . ' 00:00:00 UTC');
         $aDays = [];
-        for($i = (int)$aFilter['from']; $i < (int)$aFilter['to'] && count($aDays) < 800; $i += 86400)
-            $aDays[gmdate('Y-m-d', $i)] = true;
-        $aDays = array_keys($aDays);
+        for($i = strtotime($aBotDays['from'] . ' 00:00:00 UTC'); $i <= $iDayTo && count($aDays) < 800; $i += 86400)
+            $aDays[] = gmdate('Y-m-d', $i);
 
         $aSeries = [];
         foreach($this->_oDb->getBotSeries($aFilter) as $aRow)

+ 2 - 2
modules/boonex/analytics/install/langs/en.xml

@@ -415,7 +415,7 @@
 	<string name="_bx_analytics_txt_location"><![CDATA[Location]]></string>
 	<string name="_bx_analytics_txt_map_label"><![CDATA[Map of visitors by location]]></string>
 	<string name="_bx_analytics_txt_map_unknown"><![CDATA[{0} visitors from unknown locations are not on the map.]]></string>
-	<string name="_bx_analytics_txt_match_hint"><![CDATA[A page path starts with / and can use * for any characters, for example /blogs/*. An action is an event name such as signup, or name@module for one module, for example added@bx_posts.]]></string>
+	<string name="_bx_analytics_txt_match_hint"><![CDATA[A page path starts with / or *, and * stands for any characters: *view-post* matches the post page on the site and in the apps. An action is an event name such as signup, or name@module for one module, for example content_add@bx_posts.]]></string>
 	<string name="_bx_analytics_txt_member"><![CDATA[member]]></string>
 	<string name="_bx_analytics_txt_member_name"><![CDATA[Member]]></string>
 	<string name="_bx_analytics_txt_metric"><![CDATA[Metric]]></string>
@@ -684,7 +684,7 @@
 	<string name="_bx_analytics_txt_filter_by"><![CDATA[Show only {0}]]></string>
 	<string name="_bx_analytics_txt_first_seen"><![CDATA[First seen]]></string>
 	<string name="_bx_analytics_txt_funnel_delete_confirm"><![CDATA[Delete the funnel "{0}"?]]></string>
-	<string name="_bx_analytics_txt_funnel_hint"><![CDATA[Fill in 2 to 6 steps in the order visitors take them. A visit counts for a step when it reaches that step after the previous one. Each step is a page path (/blogs/*) or an action (signup, added@bx_posts).]]></string>
+	<string name="_bx_analytics_txt_funnel_hint"><![CDATA[Fill in 2 to 6 steps in the order visitors take them. A visit counts for a step when it reaches that step after the previous one. Each step is a page path (*view-post* for the post page on every platform) or an action (signup, content_add@bx_posts).]]></string>
 	<string name="_bx_analytics_txt_funnel_invalid"><![CDATA[Enter a name and 2 to 6 steps. Page paths start with / (or *); action names use letters, digits and _ . : / -]]></string>
 	<string name="_bx_analytics_txt_goal_badge_event"><![CDATA[Action]]></string>
 	<string name="_bx_analytics_txt_goal_badge_page"><![CDATA[Page]]></string>

+ 45 - 0
modules/boonex/analytics/js/dashboard.js

@@ -108,8 +108,17 @@ BxAnalyticsDashboard.prototype.init = function () {
         oEvent.preventDefault();
     });
 
+    // a focused tab in a row that scrolls is moved clear of the row's faded ends; focus()
+    // alone scrolls only a tab that is out of sight, not one that is cut off
+    document.addEventListener('focusin', function (oEvent) {
+        var oTab = oEvent.target.closest('[data-bx-analytics-tab]');
+        if (oTab)
+            $this.showTab(oTab);
+    });
+
     this.drawCharts(document);
     this.drawMaps(document);
+    this.showSelectedTabs(document);
 
     // blocks that follow live data reload themselves while the page is visible
     setInterval(function () {
@@ -260,6 +269,7 @@ BxAnalyticsDashboard.prototype.reload = function (oBlock, bKeepFocus, bQuiet) {
             oNew._bxAnalyticsLoaded = Date.now();
             $this.drawCharts(oNew);
             $this.drawMaps(oNew);
+            $this.showSelectedTabs(oNew);
             if (typeof bx_time === 'function')
                 bx_time(undefined, false, oNew);
 
@@ -284,6 +294,41 @@ BxAnalyticsDashboard.prototype.reload = function (oBlock, bKeepFocus, bQuiet) {
         });
 };
 
+/**
+ * Scroll each tab row that is wider than its card (touch screens) to its selected tab, as
+ * a fresh row starts at its first tab.
+ */
+BxAnalyticsDashboard.prototype.showSelectedTabs = function (oRoot) {
+    var $this = this;
+    oRoot.querySelectorAll('[data-bx-analytics-tab][aria-selected="true"]').forEach(function (oTab) {
+        $this.showTab(oTab);
+    });
+};
+
+/**
+ * Scroll a tab's row, not the page, so that the whole tab shows clear of the row's faded
+ * ends (its scroll-padding).
+ */
+BxAnalyticsDashboard.prototype.showTab = function (oTab) {
+    var oRow = oTab.parentNode;
+    if (!oRow || oRow.scrollWidth <= oRow.clientWidth)
+        return;
+
+    var oStyle = window.getComputedStyle(oRow);
+    var fPadStart = parseFloat(oStyle.scrollPaddingInlineStart) || 0;
+    var fPadEnd = parseFloat(oStyle.scrollPaddingInlineEnd) || 0;
+    var bRtl = oStyle.direction === 'rtl';
+    var fPadLeft = bRtl ? fPadEnd : fPadStart;
+    var fPadRight = bRtl ? fPadStart : fPadEnd;
+
+    var oRowRect = oRow.getBoundingClientRect();
+    var oTabRect = oTab.getBoundingClientRect();
+    if (oTabRect.left < oRowRect.left + fPadLeft)
+        oRow.scrollLeft -= oRowRect.left + fPadLeft - oTabRect.left;
+    else if (oTabRect.right > oRowRect.right - fPadRight)
+        oRow.scrollLeft += oTabRect.right - oRowRect.right + fPadRight;
+};
+
 /**
  * Keyboard focus after a block opened something: the first field of a form it shows, or the
  * control for the same thing (a back or cancel leads to the control that opened it).

+ 64 - 4
modules/boonex/analytics/template/css/dashboard.css

@@ -245,22 +245,82 @@ html.dark .bx-analytics-std {
     margin-top: 1rem;
 }
 
-/* tabbed top lists */
+/*
+ * Tabbed top lists. On a touch screen the tabs stay in one row, and a row wider than its card scrolls sideways, like the site's main
+ * menu bar, with the scrollbar hidden; where scroll-driven animations work, the clipped end fades out to show there is more. The
+ * fade stops at the ends, and the script scrolls the selected or focused tab clear of it (scroll-padding sets by how much). With a
+ * mouse, which can't swipe a row, the tabs wrap onto a second line instead.
+ */
+@property --bx-analytics-tabs-fade-start {
+    syntax: "<length>";
+    inherits: false;
+    initial-value: 0px;
+}
+@property --bx-analytics-tabs-fade-end {
+    syntax: "<length>";
+    inherits: false;
+    initial-value: 0px;
+}
 .bx-analytics-tabs {
     display: flex;
-    flex-wrap: wrap;
-    gap: 0 0.25rem;
+    flex-wrap: nowrap;
+    min-width: 0;
     margin-bottom: 0.75rem;
+    overflow-x: auto;
+    overflow-y: hidden;
+    overscroll-behavior-x: contain;
+    scroll-padding-inline: 2rem;
+    scrollbar-width: none;
+    -webkit-overflow-scrolling: touch;
     box-shadow: inset 0 -1px 0 var(--bx-analytics-border);
 }
+.bx-analytics-tabs::-webkit-scrollbar {
+    display: none;
+}
+@supports (animation-timeline: scroll()) {
+    .bx-analytics-tabs {
+        -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--bx-analytics-tabs-fade-start), #000 calc(100% - var(--bx-analytics-tabs-fade-end)), transparent);
+        mask-image: linear-gradient(to right, transparent, #000 var(--bx-analytics-tabs-fade-start), #000 calc(100% - var(--bx-analytics-tabs-fade-end)), transparent);
+        animation: bx-analytics-tabs-fade linear both;
+        animation-timeline: scroll(self inline);
+    }
+}
+@keyframes bx-analytics-tabs-fade {
+    from {
+        --bx-analytics-tabs-fade-start: 0px;
+        --bx-analytics-tabs-fade-end: 2rem;
+    }
+    10%, 90% {
+        --bx-analytics-tabs-fade-start: 2rem;
+        --bx-analytics-tabs-fade-end: 2rem;
+    }
+    to {
+        --bx-analytics-tabs-fade-start: 2rem;
+        --bx-analytics-tabs-fade-end: 0px;
+    }
+}
+@media (hover: hover) and (pointer: fine) {
+    .bx-analytics-tabs {
+        flex-wrap: wrap;
+        column-gap: 0.25rem;
+        overflow: visible;
+        -webkit-mask-image: none;
+        mask-image: none;
+        animation: none;
+    }
+}
 .bx-analytics .bx-analytics-tab {
     flex: none;
     min-height: 2.75rem;
-    padding: 0 0.625rem;
+    padding: 0 0.5rem;
     font-size: 0.9375rem;
     color: var(--bx-analytics-muted);
     white-space: nowrap;
 }
+.bx-analytics .bx-analytics-tabs .bx-analytics-tab:focus-visible {
+    /* inside the tab, so the scrolling row does not clip it; outranks the site's body:not(.bx-std) [tabindex="0"]:focus-visible */
+    outline-offset: -2px;
+}
 .bx-analytics .bx-analytics-tab:hover {
     color: inherit;
 }

+ 2 - 2
modules/boonex/russian/data/langs/bx_analytics/ru.xml

@@ -412,7 +412,7 @@
 	<string name="_bx_analytics_txt_location"><![CDATA[Местоположение]]></string>
 	<string name="_bx_analytics_txt_map_label"><![CDATA[Карта посетителей по местоположению]]></string>
 	<string name="_bx_analytics_txt_map_unknown"><![CDATA[{0} посетителей из неизвестных мест на карте не показаны.]]></string>
-	<string name="_bx_analytics_txt_match_hint"><![CDATA[Путь страницы начинается с / и может содержать * вместо любых символов, например /blogs/*. Действие задаётся названием события, например signup, или название@модуль для одного модуля, например added@bx_posts.]]></string>
+	<string name="_bx_analytics_txt_match_hint"><![CDATA[Путь страницы начинается с / или *, а * заменяет любые символы: *view-post* подходит для страницы записи на сайте и в приложениях. Действие задаётся названием события, например signup, или название@модуль для одного модуля, например content_add@bx_posts.]]></string>
 	<string name="_bx_analytics_txt_member"><![CDATA[участник]]></string>
 	<string name="_bx_analytics_txt_member_name"><![CDATA[Участник]]></string>
 	<string name="_bx_analytics_txt_metric"><![CDATA[Показатель]]></string>
@@ -681,7 +681,7 @@
 	<string name="_bx_analytics_txt_filter_by"><![CDATA[Показать только {0}]]></string>
 	<string name="_bx_analytics_txt_first_seen"><![CDATA[Впервые]]></string>
 	<string name="_bx_analytics_txt_funnel_delete_confirm"><![CDATA[Удалить воронку «{0}»?]]></string>
-	<string name="_bx_analytics_txt_funnel_hint"><![CDATA[Заполните от 2 до 6 шагов в том порядке, в котором их проходят посетители. Визит засчитывается для шага, если дошёл до него после предыдущего. Каждый шаг: путь страницы (/blogs/*) или действие (signup, added@bx_posts).]]></string>
+	<string name="_bx_analytics_txt_funnel_hint"><![CDATA[Заполните от 2 до 6 шагов в том порядке, в котором их проходят посетители. Визит засчитывается для шага, если дошёл до него после предыдущего. Каждый шаг: путь страницы (*view-post* для страницы записи на любой платформе) или действие (signup, content_add@bx_posts).]]></string>
 	<string name="_bx_analytics_txt_funnel_invalid"><![CDATA[Введите название и от 2 до 6 шагов. Пути страниц начинаются с / (или *); названия действий состоят из букв, цифр и символов _ . : / -]]></string>
 	<string name="_bx_analytics_txt_goal_badge_event"><![CDATA[Действие]]></string>
 	<string name="_bx_analytics_txt_goal_badge_page"><![CDATA[Страница]]></string>

+ 267 - 0
tests/integration/analytics/AnalyticsActionPatternsTest.php

@@ -0,0 +1,267 @@
+<?php
+
+/**
+ * Goal and funnel patterns for the site actions the module records, and the examples the
+ * goal and funnel forms show. Site actions go through the module's own alert handler with a
+ * collector dated in September 2001, like the other analytics tests, and are deleted afterwards.
+ */
+class AnalyticsActionPatternsTest extends BxDolIntegrationTestCase
+{
+    // 2001-09-09 01:46:40 UTC
+    const NOW = 1000000000;
+
+    const UA_SAFARI = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15';
+    const UA_APP = 'NEO/1 CFNetwork/1.0 Darwin/24.0.0';
+
+    const ACCOUNT = 987654351;
+    const PROFILE = 987654352;
+
+    // the hints that show what a goal or a funnel step can match
+    const HINTS = ['_bx_analytics_txt_match_hint', '_bx_analytics_txt_funnel_hint'];
+
+    // the generic form the English hint explains the syntax with, not an example
+    const PLACEHOLDER = 'name@module';
+
+    protected $_oModule;
+    protected $_oAlertModule;
+    protected $_aOptions = [];
+    protected $_aGoals = [];
+    protected $_aFunnels = [];
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        if (!BxDolModuleQuery::getInstance()->isEnabledByName('bx_analytics'))
+            $this->markTestSkipped('bx_analytics is not installed');
+
+        $this->_oModule = BxDolModule::getInstance('bx_analytics');
+        $this->_oModule->getCollector(); // loads the collector classes
+        $this->_oModule->getFilter([]);
+
+        // the module's alert handler, with a collector for the test's request and time
+        $aModule = BxDolModuleQuery::getInstance()->getModuleByName('bx_analytics');
+        $this->_oAlertModule = new class($aModule) extends BxAnalyticsModule {
+            public $oCollector = null;
+            public function getCollector()
+            {
+                return $this->oCollector ? $this->oCollector : parent::getCollector();
+            }
+        };
+
+        $this->_cleanup();
+
+        foreach (['bx_analytics_track_actions' => 'on', 'bx_analytics_tracking' => 'on', 'bx_analytics_platforms' => 'core-web,next-web,rn-ios,rn-android,api,core', 'bx_analytics_respect_dnt' => 'on', 'bx_analytics_link_members' => 'on', 'bx_analytics_identity' => 'cookieless', 'bx_analytics_exclude_ips' => '', 'bx_analytics_exclude_levels' => '', 'bx_analytics_exclude_paths' => ''] as $sName => $sValue)
+            $this->_setOption($sName, $sValue);
+    }
+
+    protected function tearDown(): void
+    {
+        if ($this->_oModule) {
+            $this->_cleanup();
+            foreach ($this->_aOptions as $sName => $sValue)
+                setParam($sName, $sValue);
+        }
+
+        parent::tearDown();
+    }
+
+    public function testGoalAndFunnelCountAVisitThatAddedAPost()
+    {
+        $oDb = $this->_oModule->_oDb;
+        $aGoal = ['type' => 'event', 'pattern' => 'content_add@bx_posts'];
+        $iGoal = $oDb->saveGoal(0, ['name' => 'Test 2001 new post'] + $aGoal);
+        $iFunnel = $oDb->saveFunnel(0, 'Test 2001 posting', [
+            ['type' => 'page', 'pattern' => '/page/create-post'],
+            $aGoal,
+        ]);
+        $this->_aGoals = [$iGoal];
+        $this->_aFunnels = [$iFunnel];
+
+        // A adds a post, B adds a photo, C only looks at the form
+        $aVisits = [['93.184.216.61', 'bx_posts'], ['93.184.216.62', 'bx_photos'], ['93.184.216.63', '']];
+        foreach ($aVisits as $iVisit => list($sIp, $sUnit)) {
+            $aServer = $this->_server($sIp);
+            $iNow = self::NOW + $iVisit * 100;
+            $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [['type' => 'pageview', 'url' => 'https://una.test/page/create-post']]], $iNow);
+            if ($sUnit !== '')
+                $this->_alert($aServer, $iNow + 30, $sUnit, 'added', 70 + $iVisit, self::PROFILE + $iVisit);
+        }
+
+        $aSessions = $this->_sessions();
+        $this->assertCount(3, $aSessions);
+        $aEvents = BxDolDb::getInstance()->getAll("SELECT `session_id`, `name`, `module`, `object_id` FROM `bx_analytics_events` WHERE `type` = 'una' AND `ts` < :to ORDER BY `id`", ['to' => self::NOW + 86400]);
+        $this->assertEquals([
+            ['session_id' => $aSessions[0]['id'], 'name' => 'content_add', 'module' => 'bx_posts', 'object_id' => 70],
+            ['session_id' => $aSessions[1]['id'], 'name' => 'content_add', 'module' => 'bx_photos', 'object_id' => 71],
+        ], $aEvents, 'a new post is the site action content_add of the module, in the visit');
+
+        $oReports = $this->_oModule->getReports();
+        $oFilter = $this->_filter();
+
+        $aGoals = array_column($oReports->getGoals($oFilter)['goals'], null, 'id');
+        $this->assertSame(1, $aGoals[$iGoal]['conversions']);
+        $this->assertEquals(33.33, $aGoals[$iGoal]['rate']);
+
+        $aFilter = $oFilter->get();
+        $this->assertSame(2, (int)$oDb->getGoalTotals($aFilter, ['type' => 'event', 'pattern' => 'content_add'])['sessions'], 'without a module, new content of any module');
+        $this->assertSame(0, (int)$oDb->getGoalTotals($aFilter, ['type' => 'event', 'pattern' => 'added@bx_posts'])['sessions'], 'the alert\'s name is not the action\'s');
+
+        $aFunnel = $oReports->getFunnels($oFilter, ['funnel' => $iFunnel]);
+        $this->assertSame([3, 1], array_column($aFunnel['steps'], 'sessions'));
+    }
+
+    public function testHintExamplesAreRecordedActions()
+    {
+        // one visit that does every site action the alert handler records
+        $aServer = $this->_server('93.184.216.64');
+        $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [['type' => 'pageview', 'url' => 'https://una.test/page/view-post?id=70']]], self::NOW);
+        $aAlerts = [
+            // the test account has no profile, so it signs in as the guest the visit started as
+            ['account', 'add', self::ACCOUNT, 0, []],
+            ['account', 'login', self::ACCOUNT, 0, []],
+            ['profile', 'add', self::PROFILE, self::PROFILE, ['module' => 'bx_persons']],
+            ['bx_posts', 'added', 70, self::PROFILE, []],
+            ['comment', 'added', 1, self::PROFILE, ['object_system' => 'bx_posts', 'object_id' => 70]],
+            ['vote', 'do', 1, self::PROFILE, ['object_system' => 'bx_posts_reactions', 'object_id' => 70, 'reaction' => 'like']],
+            ['system', 'connection_added', 5, self::PROFILE, ['object_name' => 'sys_profiles_friends', 'content' => 6]],
+        ];
+        foreach ($aAlerts as $i => list($sUnit, $sAction, $iObject, $iSender, $aExtras))
+            $this->_alert($aServer, self::NOW + 10 + $i, $sUnit, $sAction, $iObject, $iSender, $aExtras);
+
+        // the same post page as the other clients record it: the classic site without
+        // permalinks, NEO on the web and the apps (the UNA page path, see NEO's toPagePath)
+        $aPostPages = [
+            ['93.184.216.65', self::UA_SAFARI, ['platform' => 'core-web', 'url' => 'https://una.test/page.php?i=view-post&id=70']],
+            ['93.184.216.66', self::UA_SAFARI, ['platform' => 'next-web', 'url' => 'https://neo.test/view-post?id=70']],
+            ['93.184.216.67', self::UA_APP, ['platform' => 'rn-ios', 'app' => 'com.neo.so', 'url' => '/view-post?id=70']],
+            ['93.184.216.68', self::UA_APP, ['platform' => 'rn-android', 'app' => 'com.neo.so', 'url' => '/view-post?id=70']],
+        ];
+        foreach ($aPostPages as $i => list($sIp, $sUserAgent, $aHit)) {
+            $sUrl = $aHit['url'];
+            unset($aHit['url']);
+            $this->_collect($this->_server($sIp, $sUserAgent), $aHit + ['hits' => [['type' => 'pageview', 'url' => $sUrl]]], self::NOW + 100 + $i);
+        }
+
+        $aSessions = $this->_sessions();
+        $this->assertCount(1 + count($aPostPages), $aSessions);
+        $this->assertEquals(['core-web', 'core-web', 'next-web', 'rn-ios', 'rn-android'], array_column($aSessions, 'platform'));
+        $aRecorded = BxDolDb::getInstance()->getColumn("SELECT DISTINCT `name` FROM `bx_analytics_events` WHERE `type` = 'una' AND `session_id` = :id", ['id' => $aSessions[0]['id']]);
+        $this->assertCount(count($aAlerts), $aRecorded, 'every alert is a site action of the visit');
+
+        // every action has a title of its own in the reports
+        $aEn = $this->_strings(BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/install/langs/en.xml');
+        $aRu = $this->_strings(BX_DIRECTORY_PATH_MODULES . 'boonex/russian/data/langs/bx_analytics/ru.xml');
+        foreach ($aRecorded as $sName) {
+            $this->assertArrayHasKey('_bx_analytics_action_' . $sName, $aEn, $sName);
+            $this->assertArrayHasKey('_bx_analytics_action_' . $sName, $aRu, $sName);
+        }
+
+        $oDb = $this->_oModule->_oDb;
+        $aFilter = $this->_filter()->get();
+        foreach (self::HINTS as $sKey) {
+            $this->assertArrayHasKey($sKey, $aEn);
+            $this->assertArrayHasKey($sKey, $aRu);
+
+            // Russian prose is Cyrillic, so its Latin words are the examples
+            $aExamples = $this->_examples($aRu[$sKey], '/(?<![\p{L}\d_@])[a-z][a-z0-9_]*(?:@[a-z0-9_]+)?/u');
+            $this->assertNotEmpty($aExamples['page'], $sKey . ': a page example');
+            $this->assertNotEmpty(array_filter($aExamples['event'], function ($s) { return strpos($s, '@') !== false; }), $sKey . ': an action of one module');
+
+            // the English hint shows the same examples
+            $aEnExamples = $this->_examples($aEn[$sKey], '/(?<![\p{L}\d_@])[a-z][a-z0-9_]*@[a-z0-9_]+/u');
+            $this->assertSame($aExamples['page'], $aEnExamples['page'], $sKey);
+            $this->assertSame(array_values(array_filter($aExamples['event'], function ($s) { return strpos($s, '@') !== false; })), array_values(array_diff($aEnExamples['event'], [self::PLACEHOLDER])), $sKey);
+            foreach ($aExamples['event'] as $sExample)
+                $this->assertMatchesRegularExpression('/(?<![\p{L}\d_@])' . preg_quote($sExample, '/') . '(?![\p{L}\d_@])/u', $aEn[$sKey], $sKey . ': ' . $sExample);
+
+            foreach ($aExamples['event'] as $sPattern)
+                $this->assertContains(explode('@', $sPattern)[0], $aRecorded, $sKey . ': ' . $sPattern . ' names an action the module records');
+
+            // and each of them, as a goal, counts the visit that did it; a page example
+            // counts the post page on every client
+            foreach ($aExamples as $sType => $aPatterns)
+                foreach ($aPatterns as $sPattern)
+                    $this->assertSame($sType == 'page' ? count($aSessions) : 1, (int)$oDb->getGoalTotals($aFilter, ['type' => $sType, 'pattern' => $sPattern])['sessions'], $sKey . ': ' . $sPattern . ' matches');
+        }
+    }
+
+    /**
+     * Page paths and action names a hint gives as examples.
+     */
+    protected function _examples($sText, $sActionRegExp)
+    {
+        $aPages = [];
+        if (preg_match_all('#(?<![\p{L}\d*/])[/*][\p{L}\d_\-/*?=.]*[\p{L}\d_\-/*?=]#u', $sText, $aMatches))
+            $aPages = array_values(array_unique($aMatches[0]));
+
+        $sProse = str_replace($aPages, ' ', $sText);
+        $aEvents = preg_match_all($sActionRegExp, $sProse, $aMatches) ? array_values(array_unique($aMatches[0])) : [];
+
+        return ['page' => $aPages, 'event' => $aEvents];
+    }
+
+    protected function _strings($sFile)
+    {
+        $this->assertFileExists($sFile);
+
+        $aResult = [];
+        foreach (simplexml_load_file($sFile)->xpath('//string') as $oString)
+            $aResult[(string)$oString['name']] = (string)$oString;
+
+        return $aResult;
+    }
+
+    protected function _alert($aServer, $iNow, $sUnit, $sAction, $iObject, $iSender, $aExtras = [])
+    {
+        $this->_oAlertModule->oCollector = new BxAnalyticsCollector($this->_oModule, new BxAnalyticsRequest($aServer), $iNow);
+        $this->_oAlertModule->onAlert(new BxDolAlerts($sUnit, $sAction, $iObject, $iSender, $aExtras));
+        $this->_oAlertModule->oCollector = null;
+    }
+
+    protected function _collect($aServer, $aPayload, $iNow)
+    {
+        $oCollector = new BxAnalyticsCollector($this->_oModule, new BxAnalyticsRequest($aServer), $iNow);
+
+        return $oCollector->collect($aPayload, ['platform' => 'api']);
+    }
+
+    protected function _filter()
+    {
+        return new BxAnalyticsFilter($this->_oModule, ['range' => 'custom', 'from' => '2001-09-09', 'to' => '2001-09-09'], self::NOW + 86400);
+    }
+
+    protected function _server($sIp, $sUserAgent = self::UA_SAFARI)
+    {
+        return ['REMOTE_ADDR' => $sIp, 'HTTP_USER_AGENT' => $sUserAgent, 'REQUEST_METHOD' => 'POST'];
+    }
+
+    protected function _sessions()
+    {
+        return BxDolDb::getInstance()->getAll("SELECT * FROM `bx_analytics_sessions` WHERE `started` < :to ORDER BY `id`", ['to' => self::NOW + 30 * 86400]);
+    }
+
+    protected function _setOption($sName, $sValue)
+    {
+        if (!array_key_exists($sName, $this->_aOptions))
+            $this->_aOptions[$sName] = getParam($sName);
+
+        setParam($sName, $sValue);
+    }
+
+    protected function _cleanup()
+    {
+        $oDb = BxDolDb::getInstance();
+        $iTo = self::NOW + 30 * 86400;
+        $oDb->query("DELETE FROM `bx_analytics_events` WHERE `ts` < :to", ['to' => $iTo]);
+        $oDb->query("DELETE FROM `bx_analytics_sessions` WHERE `started` < :to", ['to' => $iTo]);
+        $oDb->query("DELETE FROM `bx_analytics_salts` WHERE `day` < '2002-01-01'");
+        $oDb->query("DELETE FROM `bx_analytics_limits` WHERE `bucket` < 1009843200");
+        foreach ($this->_aGoals as $iId)
+            $this->_oModule->_oDb->deleteGoal($iId);
+        foreach ($this->_aFunnels as $iId)
+            $this->_oModule->_oDb->deleteFunnel($iId);
+        $this->_aGoals = $this->_aFunnels = [];
+        $this->_oModule->cleanReportCache(); // reports of the test data cached by the run before
+    }
+}

+ 107 - 0
tests/integration/analytics/AnalyticsBotsChartTest.php

@@ -0,0 +1,107 @@
+<?php
+
+use PHPUnit\Framework\Attributes\DataProvider;
+
+/**
+ * The Bots and agents chart against bot rows, which are stored per UTC day. A period in a
+ * timezone east or west of UTC touches one more UTC day than it has local days, and the
+ * chart must have exactly the UTC days the table reads, today's included.
+ * Rows are dated 2001, far from real traffic, and deleted afterwards.
+ */
+class AnalyticsBotsChartTest extends BxDolIntegrationTestCase
+{
+    // 2001-09-09 05:30 UTC: 15:30 in Sydney (UTC+10), 22:30 on 2001-09-08 in Los Angeles (UTC-7)
+    const NOW_SEPTEMBER = 1000013400;
+
+    // 2001-10-30 05:30 UTC: 16:30 in Sydney (UTC+11 since 2001-10-28), 21:30 on 2001-10-29
+    // in Los Angeles (UTC-8 since 2001-10-28); the week before crosses both changes
+    const NOW_OCTOBER = 1004419800;
+
+    // hits on the UTC day of "now", told apart from the 1 hit of every other day
+    const TODAY_HITS = 7;
+
+    protected $_oModule;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        if (!BxDolModuleQuery::getInstance()->isEnabledByName('bx_analytics'))
+            $this->markTestSkipped('bx_analytics is not installed');
+
+        $this->_oModule = BxDolModule::getInstance('bx_analytics');
+        $this->_oModule->getFilter([]); // loads the filter class
+
+        $this->_cleanup();
+    }
+
+    protected function tearDown(): void
+    {
+        if ($this->_oModule)
+            $this->_cleanup();
+
+        parent::tearDown();
+    }
+
+    public static function periods()
+    {
+        return [
+            'UTC, a week' => ['UTC', self::NOW_SEPTEMBER, '7d', '2001-09-03', '2001-09-09', 7],
+            'Sydney +10, a week' => ['Australia/Sydney', self::NOW_SEPTEMBER, '7d', '2001-09-02', '2001-09-09', 8],
+            'Sydney +10, today' => ['Australia/Sydney', self::NOW_SEPTEMBER, 'today', '2001-09-08', '2001-09-09', 2],
+            'Sydney +10 to +11, a week' => ['Australia/Sydney', self::NOW_OCTOBER, '7d', '2001-10-23', '2001-10-30', 8],
+            'Los Angeles -7, a week' => ['America/Los_Angeles', self::NOW_SEPTEMBER, '7d', '2001-09-02', '2001-09-09', 8],
+            'Los Angeles -7, today' => ['America/Los_Angeles', self::NOW_SEPTEMBER, 'today', '2001-09-08', '2001-09-09', 2],
+            'Los Angeles -7 to -8, a week' => ['America/Los_Angeles', self::NOW_OCTOBER, '7d', '2001-10-23', '2001-10-30', 8],
+        ];
+    }
+
+    #[DataProvider('periods')]
+    public function testChartHasTheUtcDaysOfThePeriod($sTz, $iNow, $sRange, $sFirst, $sLast, $iDays)
+    {
+        $this->_addHits($iNow);
+
+        $oFilter = new BxAnalyticsFilter($this->_oModule, ['range' => $sRange, 'tz' => $sTz], $iNow);
+        $aReport = $this->_oModule->getReports()->getBots($oFilter, []);
+        $aChart = $aReport['chart'];
+
+        $aLabels = array_map(function ($i) { return gmdate('Y-m-d H:i:s', $i); }, $aChart['labels']);
+        $this->assertSame('UTC', $aChart['tz'], 'labels are UTC days, as the rows are');
+        $this->assertCount($iDays, $aLabels);
+        $this->assertSame($sFirst . ' 00:00:00', $aLabels[0]);
+        $this->assertSame($sLast . ' 00:00:00', end($aLabels));
+        $this->assertSame(array_values(array_unique($aLabels)), $aLabels, 'one bar per day');
+
+        $this->assertCount(1, $aChart['datasets']);
+        $aValues = $aChart['datasets'][0]['values'];
+        $this->assertCount($iDays, $aValues);
+        $this->assertSame(self::TODAY_HITS, end($aValues), "today's hits are the last bar");
+
+        // the chart and the table read the same days
+        $iTotal = (int)$this->_oModule->_oDb->getBotTotals($oFilter->get())['hits'];
+        $this->assertSame($iTotal, array_sum($aValues));
+        $this->assertSame($iTotal, array_sum(array_column(array_column($aReport['bots']['rows'], 'values'), 'hits')));
+        $this->assertSame($iDays - 1 + self::TODAY_HITS, $iTotal, 'a hit on every other day of the chart, none outside it');
+    }
+
+    /**
+     * A hit on each UTC day from 10 days before "now" to 2 days after, and more on the day of "now".
+     */
+    protected function _addHits($iNow)
+    {
+        $oDb = $this->_oModule->_oDb;
+        $sToday = gmdate('Y-m-d', $iNow);
+        for ($i = -10; $i <= 2; $i++) {
+            $sDay = gmdate('Y-m-d', $iNow + $i * 86400);
+            for ($j = $sDay == $sToday ? self::TODAY_HITS : 1; $j > 0; $j--)
+                $this->assertTrue($oDb->addBotHit($sDay, 'Googlebot', 'search', 'core-web', '/', $iNow + $i * 86400));
+        }
+    }
+
+    protected function _cleanup()
+    {
+        $oDb = BxDolDb::getInstance();
+        $oDb->query("DELETE FROM `bx_analytics_bots` WHERE `day` < '2002-01-01'");
+        $oDb->query("DELETE FROM `bx_analytics_bot_pages` WHERE `day` < '2002-01-01'");
+    }
+}