Просмотр исходного кода

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>
Andrey Yasko 1 неделя назад
Родитель
Сommit
cfe4061b3f

+ 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 /page/view-post*. 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_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 (/page/view-post*) or an action (signup, content_add@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).

+ 14 - 3
modules/boonex/analytics/template/css/dashboard.css

@@ -246,9 +246,10 @@ html.dark .bx-analytics-std {
 }
 
 /*
- * Tabbed top lists: one row of tabs. 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
- * scroll-padding keeps a tab focused by keyboard clear of it.
+ * 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>";
@@ -298,6 +299,16 @@ html.dark .bx-analytics-std {
         --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;

+ 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[Путь страницы начинается с / и может содержать * вместо любых символов, например /page/view-post*. Действие задаётся названием события, например signup, или название@модуль для одного модуля, например content_add@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 шагов в том порядке, в котором их проходят посетители. Визит засчитывается для шага, если дошёл до него после предыдущего. Каждый шаг: путь страницы (/page/view-post*) или действие (signup, content_add@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>

+ 23 - 6
tests/integration/analytics/AnalyticsActionPatternsTest.php

@@ -11,6 +11,7 @@ class AnalyticsActionPatternsTest extends BxDolIntegrationTestCase
     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;
@@ -128,8 +129,23 @@ class AnalyticsActionPatternsTest extends BxDolIntegrationTestCase
         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, $aSessions);
+        $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');
 
@@ -162,10 +178,11 @@ class AnalyticsActionPatternsTest extends BxDolIntegrationTestCase
             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
+            // 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(1, (int)$oDb->getGoalTotals($aFilter, ['type' => $sType, 'pattern' => $sPattern])['sessions'], $sKey . ': ' . $sPattern . ' matches');
+                    $this->assertSame($sType == 'page' ? count($aSessions) : 1, (int)$oDb->getGoalTotals($aFilter, ['type' => $sType, 'pattern' => $sPattern])['sessions'], $sKey . ': ' . $sPattern . ' matches');
         }
     }
 
@@ -175,7 +192,7 @@ class AnalyticsActionPatternsTest extends BxDolIntegrationTestCase
     protected function _examples($sText, $sActionRegExp)
     {
         $aPages = [];
-        if (preg_match_all('#(?<![\p{L}\d])/[\p{L}\d_\-/*?=.]*[\p{L}\d_\-/*?=]#u', $sText, $aMatches))
+        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);
@@ -214,9 +231,9 @@ class AnalyticsActionPatternsTest extends BxDolIntegrationTestCase
         return new BxAnalyticsFilter($this->_oModule, ['range' => 'custom', 'from' => '2001-09-09', 'to' => '2001-09-09'], self::NOW + 86400);
     }
 
-    protected function _server($sIp)
+    protected function _server($sIp, $sUserAgent = self::UA_SAFARI)
     {
-        return ['REMOTE_ADDR' => $sIp, 'HTTP_USER_AGENT' => self::UA_SAFARI, 'REQUEST_METHOD' => 'POST'];
+        return ['REMOTE_ADDR' => $sIp, 'HTTP_USER_AGENT' => $sUserAgent, 'REQUEST_METHOD' => 'POST'];
     }
 
     protected function _sessions()