Переглянути джерело

Analytics: NEO elements and tracker

NEO shows the analytics page as the Dashboard's Analytics subtab, on web and
in the apps: analytics_filters (period, platform, removable filter chips,
online now), analytics_overview (key figures with the change against the
previous period, and an SVG chart of the selected one) and analytics_top
(tabbed top lists; a row filters every block). The blocks on a screen share
one filter state, kept out of the URL so the Conductor keeps the subtab
selected. Each block fetches its data from bx_analytics/get_report.

The analytics page moves to layout 12 like the other account dashboard
pages: NEO's dashboard tabs show the center and the right bar and drop the
top area. The page now selects its own item in the dashboard submenu.

The NEO tracker sends pageviews (screen views on native), engaged time and
custom events (trackEvent) to bx_analytics/collect, with settings from the
new safe service get_tracker_config. NEO requests carry X-Neo-Platform
(web, ios, android), so site actions record the client; the API allows the
header in CORS preflights. Report time zones accept the legacy names that
Intl still returns on some runtimes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko 1 тиждень тому
батько
коміт
8e5d4e1729
33 змінених файлів з 1690 додано та 42 видалено
  1. 1 1
      inc/utils.inc.php
  2. 2 1
      modules/boonex/analytics/classes/BxAnalyticsFilter.php
  3. 45 1
      modules/boonex/analytics/classes/BxAnalyticsModule.php
  4. 2 1
      modules/boonex/analytics/classes/BxAnalyticsPage.php
  5. 1 0
      modules/boonex/analytics/install/langs/en.xml
  6. 7 7
      modules/boonex/analytics/install/sql/enable.sql
  7. 1 0
      modules/boonex/russian/data/langs/bx_analytics/ru.xml
  8. 2 1
      neo/AGENTS.md
  9. 2 0
      neo/apps/expo/app/(tabs)/_layout.js
  10. 2 0
      neo/apps/next/app/[...path]/page.js
  11. 2 0
      neo/apps/next/app/providers.js
  12. 6 0
      neo/packages/app/components/elements/_map.js
  13. 5 0
      neo/packages/app/components/elements/analytics.tsx
  14. 107 0
      neo/packages/app/components/elements/analytics/analytics.types.ts
  15. 269 0
      neo/packages/app/components/elements/analytics/area-chart.tsx
  16. 97 0
      neo/packages/app/components/elements/analytics/filters.tsx
  17. 98 0
      neo/packages/app/components/elements/analytics/format.ts
  18. 119 0
      neo/packages/app/components/elements/analytics/overview.tsx
  19. 34 0
      neo/packages/app/components/elements/analytics/states.tsx
  20. 118 0
      neo/packages/app/components/elements/analytics/top.tsx
  21. 130 0
      neo/packages/app/components/elements/analytics/use-analytics.ts
  22. 5 28
      neo/packages/app/components/nav/expo-screen.js
  23. 49 0
      neo/packages/app/components/nav/tab-page-url.ts
  24. 60 0
      neo/packages/app/lib/analytics/analytics-tracker.tsx
  25. 45 0
      neo/packages/app/lib/analytics/analytics-tracker.web.tsx
  26. 61 0
      neo/packages/app/lib/analytics/analytics.types.ts
  27. 73 0
      neo/packages/app/lib/analytics/client.ts
  28. 53 0
      neo/packages/app/lib/analytics/client.web.ts
  29. 3 0
      neo/packages/app/lib/analytics/index.ts
  30. 231 0
      neo/packages/app/lib/analytics/tracker.ts
  31. 24 2
      neo/packages/app/lib/fetcher.ts
  32. 26 0
      tests/integration/analytics/AnalyticsCollectorTest.php
  33. 10 0
      tests/units/modules/boonex/analytics/classes/BxAnalyticsFilterTest.php

+ 1 - 1
inc/utils.inc.php

@@ -2666,7 +2666,7 @@ function bx_api_check_access()
 
             if ('OPTIONS' == $_SERVER['REQUEST_METHOD']) {
                 header('Access-Control-Allow-Methods: POST, GET');
-                header('Access-Control-Allow-Headers: Accept-Encoding, Authorization, Cache-Control, Connection, Host, Origin, Pragma, Referer, User-Agent, X-Custom-Header, X-Requested-With');
+                header('Access-Control-Allow-Headers: Accept-Encoding, Authorization, Cache-Control, Connection, Host, Origin, Pragma, Referer, User-Agent, X-Custom-Header, X-Neo-Platform, X-Requested-With');
                 exit;
             }
         }

+ 2 - 1
modules/boonex/analytics/classes/BxAnalyticsFilter.php

@@ -96,7 +96,8 @@ class BxAnalyticsFilter
     {
         $sRange = isset($aInput['range']) && in_array($aInput['range'], self::$aRanges, true) ? $aInput['range'] : self::RANGE_DEFAULT;
 
-        $sTz = isset($aInput['tz']) && is_string($aInput['tz']) && in_array($aInput['tz'], timezone_identifiers_list(), true) ? $aInput['tz'] : 'UTC';
+        // with the backward-compatible names (Asia/Calcutta, Europe/Kiev), which Intl still reports on some runtimes
+        $sTz = isset($aInput['tz']) && is_string($aInput['tz']) && in_array($aInput['tz'], timezone_identifiers_list(DateTimeZone::ALL_WITH_BC), true) ? $aInput['tz'] : 'UTC';
         $oTz = new DateTimeZone($sTz);
 
         $oNow = (new DateTime('@' . $this->_iNow))->setTimezone($oTz);

+ 45 - 1
modules/boonex/analytics/classes/BxAnalyticsModule.php

@@ -165,6 +165,7 @@ class BxAnalyticsModule extends BxDolModule
     {
         return array_merge(parent::serviceGetSafeServices(), [
             'GetReport' => '',
+            'GetTrackerConfig' => '',
         ]);
     }
 
@@ -214,6 +215,48 @@ class BxAnalyticsModule extends BxDolModule
         return ['stored' => $iStored];
     }
 
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-tracking Tracking
+     * @subsubsection bx_analytics-get_tracker_config get_tracker_config
+     *
+     * @code bx_srv('bx_analytics', 'get_tracker_config', ['rn-ios']); @endcode
+     * @code api.php?r=bx_analytics/get_tracker_config&params[]=next-web @endcode
+     *
+     * Tracker settings for NEO and the apps, read once when the client starts:
+     * whether to send hits at all, whether to keep a visitor id (persistent identity mode),
+     * whether to honour Do Not Track, and the session timeout.
+     *
+     * @param $sPlatform client platform code (next-web, rn-ios, rn-android, api); when empty,
+     *      the platform of the request
+     * @return array with `enabled`, `persistent`, `respect_dnt` (booleans) and
+     *      `session_timeout` (seconds)
+     *
+     * @see BxAnalyticsModule::serviceGetTrackerConfig
+     */
+    /**
+     * @ref bx_analytics-get_tracker_config "get_tracker_config"
+     */
+    public function serviceGetTrackerConfig($sPlatform = '')
+    {
+        if(!is_string($sPlatform) || $sPlatform === '') {
+            bx_import('Request', $this->_aModule);
+            $oRequest = new BxAnalyticsRequest();
+            $sPlatform = $oRequest->getPlatform(defined('BX_API'), $this->_isApiKeyRequest());
+        }
+
+        // `core` is the server's own platform, never a client's
+        $bEnabled = $this->_oConfig->isTrackingEnabled() && $sPlatform != BX_ANALYTICS_PLATFORM_CORE && $this->_oConfig->isPlatformEnabled($sPlatform);
+
+        return [
+            'enabled' => $bEnabled,
+            'persistent' => $bEnabled && $this->_oConfig->getIdentityMode() == BX_ANALYTICS_IDENTITY_PERSISTENT,
+            'respect_dnt' => $this->_oConfig->isRespectDnt(),
+            'session_timeout' => $this->_oConfig->getSessionTimeout(),
+        ];
+    }
+
     /**
      * Hits from the classic tracker: same origin, a JSON body sent with sendBeacon.
      */
@@ -817,7 +860,8 @@ class BxAnalyticsModule extends BxDolModule
                 'key_figures' => _t('_bx_analytics_txt_key_figures'),
                 'visitors' => _t('_bx_analytics_metric_visitors'),
                 'no_data' => _t('_bx_analytics_txt_no_data'),
-                'error' => _t('_bx_analytics_txt_error_loading'),
+                // NEO shows a Retry button next to it, so no "reload the page"
+                'error' => _t('_bx_analytics_txt_error_report'),
                 'duration_h' => _t('_bx_analytics_txt_duration_h'),
                 'duration_m' => _t('_bx_analytics_txt_duration_m'),
                 'duration_s' => _t('_bx_analytics_txt_duration_s'),

+ 2 - 1
modules/boonex/analytics/classes/BxAnalyticsPage.php

@@ -21,7 +21,8 @@ class BxAnalyticsPage extends BxTemplPage
         if($oMenuSubmenu) {
             $sMenuSubmenu = 'sys_account_dashboard';
             $oMenuSubmenu->setObjectSubmenu($sMenuSubmenu, array('title' => _t('_sys_menu_item_title_account_dashboard'), 'link' => '', 'icon' => ''));
-            BxDolMenu::getObjectInstance($sMenuSubmenu)->setSelected($this->MODULE, 'analytics');
+            // the item's name in sys_account_dashboard, not the page URI
+            BxDolMenu::getObjectInstance($sMenuSubmenu)->setSelected($this->MODULE, 'dashboard-analytics');
         }
     }
 }

+ 1 - 0
modules/boonex/analytics/install/langs/en.xml

@@ -278,6 +278,7 @@
 	<string name="_bx_analytics_tab_utm_term"><![CDATA[UTM term]]></string>
 	<string name="_bx_analytics_txt_empty_title_item"><![CDATA[Empty title]]></string>
 	<string name="_bx_analytics_txt_error_loading"><![CDATA[Couldn't load this report. Reload the page to try again.]]></string>
+	<string name="_bx_analytics_txt_error_report"><![CDATA[Couldn't load this report.]]></string>
 	<string name="_bx_analytics_txt_event"><![CDATA[Event]]></string>
 	<string name="_bx_analytics_txt_item"><![CDATA[Item]]></string>
 	<string name="_bx_analytics_txt_journey_from"><![CDATA[Where visitors went from {0}]]></string>

+ 7 - 7
modules/boonex/analytics/install/sql/enable.sql

@@ -44,16 +44,16 @@ INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `value`, `type`, `e
 
 -- PAGES & BLOCKS
 INSERT INTO `sys_objects_page`(`object`, `title_system`, `title`, `module`, `layout_id`, `visible_for_levels`, `visible_for_levels_editable`, `uri`, `url`, `meta_description`, `meta_keywords`, `meta_robots`, `cache_lifetime`, `cache_editable`, `deletable`, `override_class_name`, `override_class_file`) VALUES
-('bx_analytics_page', '_bx_analytics_page_title_sys_page', '_bx_analytics_page_title_page', @sName, 10, 2147483647, 1, 'analytics', '', '', '', 'noindex', 0, 1, 0, 'BxAnalyticsPage', 'modules/boonex/analytics/classes/BxAnalyticsPage.php');
+('bx_analytics_page', '_bx_analytics_page_title_sys_page', '_bx_analytics_page_title_page', @sName, 12, 2147483647, 1, 'analytics', '', '', '', 'noindex', 0, 1, 0, 'BxAnalyticsPage', 'modules/boonex/analytics/classes/BxAnalyticsPage.php');
 
 INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `order`) VALUES
-('bx_analytics_page', 1, @sName, '_bx_analytics_page_block_title_system_filters', '_bx_analytics_page_block_title_filters', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:17:"get_block_filters";}', 0, 1, 1, 1),
-('bx_analytics_page', 1, @sName, '_bx_analytics_page_block_title_system_overview', '_bx_analytics_page_block_title_overview', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:18:"get_block_overview";}', 0, 1, 1, 2),
-('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_top_pages', '_bx_analytics_page_block_title_top_pages', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:5:"pages";}}', 0, 1, 1, 1),
-('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_top_locations', '_bx_analytics_page_block_title_top_locations', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"locations";}}', 0, 1, 1, 2),
-('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_top_platforms', '_bx_analytics_page_block_title_top_platforms', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"platforms";}}', 0, 1, 1, 3),
-('bx_analytics_page', 3, @sName, '_bx_analytics_page_block_title_system_top_sources', '_bx_analytics_page_block_title_top_sources', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"sources";}}', 0, 1, 1, 1),
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_filters', '_bx_analytics_page_block_title_filters', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:17:"get_block_filters";}', 0, 1, 1, 1),
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_overview', '_bx_analytics_page_block_title_overview', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:18:"get_block_overview";}', 0, 1, 1, 2),
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_top_pages', '_bx_analytics_page_block_title_top_pages', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:5:"pages";}}', 0, 1, 1, 3),
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_top_sources', '_bx_analytics_page_block_title_top_sources', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"sources";}}', 0, 1, 1, 4),
+('bx_analytics_page', 3, @sName, '_bx_analytics_page_block_title_system_top_locations', '_bx_analytics_page_block_title_top_locations', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"locations";}}', 0, 1, 1, 1),
 ('bx_analytics_page', 3, @sName, '_bx_analytics_page_block_title_system_top_devices', '_bx_analytics_page_block_title_top_devices', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"devices";}}', 0, 1, 1, 2),
+('bx_analytics_page', 3, @sName, '_bx_analytics_page_block_title_system_top_platforms', '_bx_analytics_page_block_title_top_platforms', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"platforms";}}', 0, 1, 1, 3),
 ('bx_analytics_page', 4, @sName, '_bx_analytics_page_block_title_system_canvas', '_bx_analytics_page_block_title_canvas', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:10:"get_canvas";}', 0, 1, 1, 1);
 
 -- BLOCKS: every report for the page builder

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

@@ -278,6 +278,7 @@
 	<string name="_bx_analytics_tab_utm_term"><![CDATA[UTM term]]></string>
 	<string name="_bx_analytics_txt_empty_title_item"><![CDATA[Пустой заголовок]]></string>
 	<string name="_bx_analytics_txt_error_loading"><![CDATA[Не удалось загрузить отчёт. Обновите страницу, чтобы попробовать снова.]]></string>
+	<string name="_bx_analytics_txt_error_report"><![CDATA[Не удалось загрузить отчёт.]]></string>
 	<string name="_bx_analytics_txt_event"><![CDATA[Событие]]></string>
 	<string name="_bx_analytics_txt_item"><![CDATA[Позиция]]></string>
 	<string name="_bx_analytics_txt_journey_from"><![CDATA[Куда переходили посетители со страницы {0}]]></string>

+ 2 - 1
neo/AGENTS.md

@@ -97,12 +97,13 @@ patches/         patch-package patches, applied on postinstall
 ## Talking to UNA
 
 - Call UNA through `fetcher` from `app/lib/fetcher`:
-  - `fetcher(path)` does a GET; `fetcher([path, token, body])` POSTs `body`. The full signature is `fetcher(req, useProxy = false, { signal, timeoutMs, maxAttempts, silent, onUploadProgress })`.
+  - `fetcher(path)` does a GET; `fetcher([path, token, body])` POSTs `body`. The full signature is `fetcher(req, useProxy = false, { signal, timeoutMs, maxAttempts, silent, onUploadProgress, keepalive })`.
   - It appends `&lang=`, so the path must already contain `?r=`.
   - It returns UNA's envelope `{status, module, method, params, data, hash}`; read `.data`. A body that isn't JSON comes back as `{}`.
   - GETs are retried three times on network errors, never on a timeout (`config.fetch_timeout_ms`, 30 s).
   - On web it calls same-origin `/api/...`, and `proxy.js` forwards that to UNA with the API key and the viewer's cookies. Native calls `UNA_URL` directly with `Origin: APP_ORIGIN` and the session cookie.
 - Routes are `/api.php?r={module}/{method}/{class}` with `params[]=...` or `params=[...]`; the class defaults to `Module`, system services use `Templ*` classes. UNA only allows services marked safe or public. Many endpoints come from UNA data (`'/api.php?r=' + data.request_url`); fixed ones live in `appSetting('urls', ...)`.
+- `fetcher` sends `X-Neo-Platform` (`web`, `ios`, `android`; web only through the same-origin proxy), so UNA's analytics records site actions with the right client. Pageviews and screen views go to UNA's analytics module from `AnalyticsTracker` (`app/lib/analytics`, mounted in `apps/next/app/providers.js` and the Expo tabs layout); send custom events with `trackEvent(name, props)` from the same module.
 - Three places bypass `fetcher` on purpose: the web page shell above, AI chat streaming (`ui/molecules/ai-agent/helper.js`) and direct uploads (`lib/util/upload.ts`).
 - If the page JSON can't be parsed (a PHP fatal or an HTML error page), web shows the maintenance screen for the whole page and home falls back to a static splash. Fix the UNA service; the [`una-api` skill](.agents/skills/una-api/SKILL.md) lists the rules, the usual cause being a block that fails for guests.
 - Auth: `useCurrentUser()` from `app/context/user` returns `{ currentUser, setCurrentUser }`. `currentUser` is `null` until known, `false` for a guest and an object when signed in. `Root` and the layouts hold rendering until it is known, so components rarely need to wait themselves. The guest sign-in prompt is `layout.show_login_modal`.

+ 2 - 0
neo/apps/expo/app/(tabs)/_layout.js

@@ -9,6 +9,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
 import { queryClient } from 'app/lib/platform/query-client'
 import { Provider as JotaiProvider } from 'jotai'
 import { NetworkStatus } from 'app/ui/molecules/system/net-info';
+import { AnalyticsTracker } from 'app/lib/analytics';
 
 //import RNScreenshotPrevent, { addListener } from 'react-native-screenshot-prevent';
 import i18n from 'i18next';
@@ -103,6 +104,7 @@ const AppLayout = React.memo(() => {
                         <Shell {...shellProps}>
                             <Tabs />
                             <PortalHost />
+                            <AnalyticsTracker />
                         </Shell>
                     </QueryClientProvider>
                 </NetworkStatus>

+ 2 - 0
neo/apps/next/app/[...path]/page.js

@@ -42,6 +42,8 @@ const getData = cache(async (path, searchParamsKey) => {
     const fetchHeaders = {
         cookie: cookieString,
         authorization: 'Bearer ' + (tenantUnaKey ?? UNA_API_KEY),
+        // The page is rendered for a NEO web viewer: UNA records site actions with this platform.
+        'x-neo-platform': 'web',
     };
 
     let l = tenantUnaUrl ?? UNA_URL;

+ 2 - 0
neo/apps/next/app/providers.js

@@ -12,6 +12,7 @@ import { useEffect, useState } from 'react'
 import Subscriber from 'app/ui/molecules/system/subscriber'
 import { useLayoutSettings } from 'app/context/layout-settings'
 import { addI18nResources, changeI18nLanguage } from 'app/lib/i18n-resources'
+import { AnalyticsTracker } from 'app/lib/analytics'
 
 // The Google OAuth popup returns to a page of this app: close it and hand the
 // result to the opener. Here, not in the lazily loaded Google button, so it
@@ -99,6 +100,7 @@ export function Providers({ children, initialLang = 'en', initialResources }) {
                     <SpeedInsights />
                     {children}
                     <Subscriber />
+                    <AnalyticsTracker />
                 </QueryClientProvider>
             </JotaiProvider>
         </I18nextProvider>

+ 6 - 0
neo/packages/app/components/elements/_map.js

@@ -75,6 +75,9 @@ const ReputationLeaderboard = dynamic(() => import('./reputation').then((m) => m
 const ReputationLevels = dynamic(() => import('./reputation').then((m) => m.ReputationLevels), { loading: DynamicFallback });
 const ReputationHistory = dynamic(() => import('./reputation').then((m) => m.ReputationHistory), { loading: DynamicFallback });
 const ReputationActions = dynamic(() => import('./reputation').then((m) => m.ReputationActions), { loading: DynamicFallback });
+const AnalyticsFilters = dynamic(() => import('./analytics').then((m) => m.AnalyticsFilters), { loading: DynamicFallback });
+const AnalyticsOverview = dynamic(() => import('./analytics').then((m) => m.AnalyticsOverview), { loading: DynamicFallback });
+const AnalyticsTop = dynamic(() => import('./analytics').then((m) => m.AnalyticsTop), { loading: DynamicFallback });
 
 export const componentsMapDefault = {
     messenger_main_page: Messenger,
@@ -87,6 +90,9 @@ export const componentsMapDefault = {
     reputation_history: ReputationHistory,
     reputation_actions: ReputationActions,
     reputation_widget: ReputationWidget,
+    analytics_filters: AnalyticsFilters,
+    analytics_overview: AnalyticsOverview,
+    analytics_top: AnalyticsTop,
     tasks_menu: TasksMenu,
     tasks_timers: TasksTimers,
     tasks_list: TasksList,

+ 5 - 0
neo/packages/app/components/elements/analytics.tsx

@@ -0,0 +1,5 @@
+// Analytics (bx_analytics) elements: one chunk for the three block types. Each block
+// loads its own report from get_report; all of them on a screen share one filter state.
+export { default as AnalyticsFilters } from './analytics/filters';
+export { default as AnalyticsOverview } from './analytics/overview';
+export { default as AnalyticsTop } from './analytics/top';

+ 107 - 0
neo/packages/app/components/elements/analytics/analytics.types.ts

@@ -0,0 +1,107 @@
+// Shapes of the bx_analytics (UNA Analytics module) blocks and reports.
+// Blocks: BxAnalyticsModule::_getApiBlockData. Reports: BxAnalyticsReports.
+
+export type AnalyticsOption = { value: string; title: string };
+
+/** Server-translated strings; `{0}` is a placeholder NEO fills in. */
+export type AnalyticsStrings = {
+    range: string;
+    platform: string;
+    all_platforms: string;
+    /** "online in the last {0} minutes" */
+    online: string;
+    /** "Remove filter: {0}" */
+    remove_filter: string;
+    /** "Show only {0}" */
+    filter_by: string;
+    /** "Previous period: {0}" */
+    previous_period: string;
+    key_figures: string;
+    visitors: string;
+    no_data: string;
+    error: string;
+    duration_h: string;
+    duration_m: string;
+    duration_s: string;
+};
+
+/** `data` of an analytics_* element. */
+export type AnalyticsBlockData = {
+    report: string;
+    /** The block's own report params, e.g. `{ group: 'pages' }` for a top list. */
+    params?: Record<string, unknown>;
+    /** `bx_analytics/get_report` */
+    request_url?: string;
+    ranges?: AnalyticsOption[];
+    platforms?: AnalyticsOption[];
+    /** metric => title */
+    metrics?: Record<string, string>;
+    strings?: Partial<AnalyticsStrings>;
+};
+
+export type AnalyticsElementProps = {
+    data?: AnalyticsBlockData;
+    /** Spread into BlockWrapper (a JS component whose props TypeScript infers as all required). */
+    blockWrapperProps?: any;
+};
+
+/** Filters shared by every analytics block of a screen (BxAnalyticsFilter params). */
+export type AnalyticsFilterState = {
+    range: string;
+    /** Platform code, '' for all. */
+    platform: string;
+    /** dimension => value */
+    f: Record<string, string>;
+};
+
+export type AnalyticsChip = { name: string; value: string; title: string; label: string };
+
+export type FiltersReport = {
+    chips: AnalyticsChip[];
+    online: number;
+    /** Online window, minutes. */
+    window: number;
+};
+
+export type AnalyticsKpi = { value: number; previous: number; change: number | null };
+
+export type AnalyticsBucket = 'hour' | 'day' | 'week' | 'month';
+
+/** One chart bucket: `t` is the bucket's local start (unix seconds), plus every metric. */
+export type AnalyticsPoint = {
+    t: number;
+    future: boolean;
+    partial: boolean;
+    [metric: string]: number | boolean;
+};
+
+export type OverviewReport = {
+    kpis: Record<string, AnalyticsKpi>;
+    series: AnalyticsPoint[];
+    bucket: AnalyticsBucket;
+    from: number;
+    to: number;
+    tz: string;
+};
+
+export type TopTab = { name: string; title: string; active: boolean };
+
+export type TopItem = {
+    value: string;
+    label: string;
+    visitors: number;
+    pageviews: number;
+    /** Percent of all visitors in the list. */
+    share: number;
+    /** Percent of the largest row, for the bar. */
+    bar: number;
+};
+
+export type TopReport = {
+    group: string;
+    tab: string;
+    tabs: TopTab[];
+    dimension: string;
+    column: string;
+    items: TopItem[];
+};

+ 269 - 0
neo/packages/app/components/elements/analytics/area-chart.tsx

@@ -0,0 +1,269 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { TFunction } from 'i18next';
+import type { ColorValue } from 'react-native';
+import Svg, { Circle, Line, Path } from 'react-native-svg';
+import { useCSSVariable } from 'uniwind';
+import { View, Row } from 'app/design/view';
+import { Text } from 'app/design/typography';
+import { useTheme } from 'app/design/theme';
+import { formatDate, getMonthName, isWeb, useDateLocaleTag } from 'app/lib/util';
+import type { AnalyticsBucket, AnalyticsPoint, AnalyticsStrings } from './analytics.types';
+import { formatAxis, formatMetric, getDeviceTimezone } from './format';
+
+const HEIGHT = 200;
+const PAD_TOP = 8;
+const AXIS_HEIGHT = 22;
+const GUTTER = 48;
+const PAD_RIGHT = 8;
+const X_LABEL_WIDTH = 72;
+const Y_TICKS = 4;
+/** Decimals an axis tick prints (formatAxis); every other metric prints whole numbers. */
+const AXIS_DECIMALS: Record<string, number> = { pages_per_session: 1 };
+
+type Point = { x: number; y: number };
+
+export type AreaChartProps = {
+    series: AnalyticsPoint[];
+    metric: string;
+    metricTitle: string;
+    bucket: AnalyticsBucket;
+    /** The report's timezone (OverviewReport.tz). */
+    tz: string;
+    strings: AnalyticsStrings;
+};
+
+/**
+ * Bucket start as a label. Buckets are local starts in the report's timezone: the device's
+ * (see useAnalyticsReport), or UTC when UNA doesn't know the zone the device sent (`utc`).
+ * Then the date is shifted so local Date getters read the UTC wall time.
+ */
+function formatBucket(t: number, bucket: AnalyticsBucket, utc: boolean, translate: TFunction, locale: string): string {
+    const date = new Date(t * 1000);
+    if (utc) date.setTime((t + date.getTimezoneOffset() * 60) * 1000);
+    if (bucket === 'hour') return formatDate(date, translate, { showDate: false, showTime: true, locale });
+    if (bucket === 'month') return `${getMonthName(translate, date.getMonth())} ${date.getFullYear()}`;
+    return formatDate(date, translate, { month: 'short', yearPolicy: 'never', locale });
+}
+
+/** A 1/2/2.5/5 step that the axis prints exactly with `decimals` digits. */
+function niceStep(max: number, decimals: number): number {
+    if (max <= 0) return 1;
+    const min = Math.pow(10, -decimals);
+    const raw = max / Y_TICKS;
+    const power = Math.pow(10, Math.floor(Math.log10(raw)));
+    const n = raw / power;
+    // 2.5 needs one more digit than its power of ten
+    const step = (n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 && power > min ? 2.5 : n <= 5 ? 5 : 10) * power;
+    return Math.max(min, step);
+}
+
+/** Monotone cubic tangents (Fritsch-Carlson): a smooth line that never overshoots a value. */
+function tangents(points: Point[]): number[] {
+    const n = points.length;
+    const slopes: number[] = [];
+    for (let i = 0; i < n - 1; i++) {
+        const a = points[i] as Point;
+        const b = points[i + 1] as Point;
+        slopes.push(b.x === a.x ? 0 : (b.y - a.y) / (b.x - a.x));
+    }
+    const m: number[] = points.map((_, i) => {
+        if (i === 0) return slopes[0] ?? 0;
+        if (i === n - 1) return slopes[n - 2] ?? 0;
+        const prev = slopes[i - 1] ?? 0;
+        const next = slopes[i] ?? 0;
+        return prev * next <= 0 ? 0 : (prev + next) / 2;
+    });
+    for (let i = 0; i < n - 1; i++) {
+        const d = slopes[i] ?? 0;
+        if (d === 0) {
+            m[i] = 0;
+            m[i + 1] = 0;
+            continue;
+        }
+        const a = (m[i] ?? 0) / d;
+        const b = (m[i + 1] ?? 0) / d;
+        const s = a * a + b * b;
+        if (s > 9) {
+            const k = 3 / Math.sqrt(s);
+            m[i] = k * a * d;
+            m[i + 1] = k * b * d;
+        }
+    }
+    return m;
+}
+
+function curve(a: Point, b: Point, ma: number, mb: number): string {
+    const dx = (b.x - a.x) / 3;
+    return `C ${a.x + dx} ${a.y + ma * dx} ${b.x - dx} ${b.y - mb * dx} ${b.x} ${b.y}`;
+}
+
+function useChartColors() {
+    const { colors } = useTheme();
+    const [primary, border, muted] = useCSSVariable(['--color-primary', '--color-border', '--color-muted-foreground']);
+    if (isWeb) return { line: 'var(--color-primary)', grid: 'var(--color-border)', guide: 'var(--color-muted-foreground)' };
+    const pick = (value: string | number | undefined, fallback: ColorValue): ColorValue => (typeof value === 'string' && value ? value : fallback);
+    return {
+        line: pick(primary, colors.primary),
+        grid: pick(border, colors.border),
+        guide: pick(muted, colors.text),
+    };
+}
+
+/**
+ * One metric over the period as an area chart, like the classic overview chart: the
+ * bucket still filling up ("now") is drawn dashed, future buckets are left empty.
+ * Touch or hover shows a bucket's value; screen readers get every value as text.
+ */
+export function AreaChart({ series, metric, metricTitle, bucket, tz, strings }: AreaChartProps) {
+    const { t } = useTranslation();
+    const locale = useDateLocaleTag();
+    const colors = useChartColors();
+    const [width, setWidth] = useState(0);
+    const [active, setActive] = useState<number | null>(null);
+
+    const values = series.map((point) => (point.future ? null : Number(point[metric]) || 0));
+    let last = -1;
+    values.forEach((value, i) => {
+        if (value !== null) last = i;
+    });
+    const utc = tz !== getDeviceTimezone();
+    const labels = series.map((point) => formatBucket(point.t, bucket, utc, t, locale));
+
+    const summary = `${metricTitle}: ` + values
+        .slice(0, last + 1)
+        .map((value, i) => `${labels[i]} ${formatMetric(metric, value, strings)}`)
+        .join(', ');
+
+    const max = Math.max(0, ...values.map((value) => value ?? 0));
+    const step = niceStep(max, AXIS_DECIMALS[metric] ?? 0);
+    const yMax = Math.max(step, Math.ceil(max / step) * step);
+    const ticks = Array.from({ length: Math.round(yMax / step) + 1 }, (_, i) => i * step);
+
+    const plotWidth = Math.max(0, width - GUTTER - PAD_RIGHT);
+    const plotHeight = HEIGHT - PAD_TOP - AXIS_HEIGHT;
+    const baseline = PAD_TOP + plotHeight;
+    const n = series.length;
+    const xStep = n > 1 ? plotWidth / (n - 1) : 0;
+    const xAt = (i: number) => GUTTER + (n > 1 ? i * xStep : plotWidth / 2);
+    const yAt = (value: number) => PAD_TOP + plotHeight * (1 - value / yMax);
+
+    const points: Point[] = values.slice(0, last + 1).map((value, i) => ({ x: xAt(i), y: yAt(value ?? 0) }));
+    const m = tangents(points);
+
+    let solid = '';
+    let dashed = '';
+    let solidEnd = -1;
+    for (let i = 1; i < points.length; i++) {
+        const a = points[i - 1] as Point;
+        const b = points[i] as Point;
+        const segment = curve(a, b, m[i - 1] ?? 0, m[i] ?? 0);
+        if (series[i]?.partial) {
+            dashed += `M ${a.x} ${a.y} ${segment} `;
+        } else {
+            solid += (solidEnd === i - 1 ? '' : `M ${a.x} ${a.y} `) + segment + ' ';
+            solidEnd = i;
+        }
+    }
+    const first = points[0];
+    const end = points[points.length - 1];
+    let area = '';
+    if (first && end && points.length > 1) {
+        area = `M ${first.x} ${first.y} `;
+        for (let i = 1; i < points.length; i++) area += curve(points[i - 1] as Point, points[i] as Point, m[i - 1] ?? 0, m[i] ?? 0) + ' ';
+        area += `L ${end.x} ${baseline} L ${first.x} ${baseline} Z`;
+    }
+
+    const maxLabels = Math.max(2, Math.floor(plotWidth / X_LABEL_WIDTH));
+    const labelEvery = Math.max(1, Math.ceil(n / maxLabels));
+
+    const activeIndex = active !== null && active <= last ? active : null;
+    const activePoint = activeIndex !== null ? points[activeIndex] : undefined;
+
+    const pick = (x: number) => {
+        if (last < 0 || !plotWidth) return;
+        const i = n > 1 ? Math.round((x - GUTTER) / xStep) : 0;
+        setActive(Math.min(last, Math.max(0, i)));
+    };
+
+    const pointerProps = isWeb
+        ? {
+            // the overlay has no children, so the offset is already relative to it
+            onMouseMove: (event: { nativeEvent: { offsetX: number } }) => pick(event.nativeEvent.offsetX),
+            onMouseLeave: () => setActive(null),
+        }
+        : {
+            onStartShouldSetResponder: () => true,
+            onResponderGrant: (event: { nativeEvent: { locationX: number } }) => pick(event.nativeEvent.locationX),
+            onResponderMove: (event: { nativeEvent: { locationX: number } }) => pick(event.nativeEvent.locationX),
+        };
+
+    return (
+        <View className="w-full gap-1">
+            <Row className="min-h-5 items-center justify-between gap-2" aria-hidden accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
+                <Text className="text-sm font-medium text-foreground" numberOfLines={1}>{metricTitle}</Text>
+                {activeIndex !== null ? (
+                    <Text className="text-sm text-muted-foreground" numberOfLines={1}>
+                        {`${labels[activeIndex]}: ${formatMetric(metric, values[activeIndex], strings)}`}
+                    </Text>
+                ) : null}
+            </Row>
+            <View
+                className="relative w-full"
+                style={{ height: HEIGHT }}
+                onLayout={(event) => setWidth(Math.round(event.nativeEvent.layout.width))}
+                accessible
+                role="img"
+                accessibilityLabel={summary}
+            >
+                {width > 0 ? (
+                    <Svg width={width} height={HEIGHT} viewBox={`0 0 ${width} ${HEIGHT}`}>
+                        {ticks.map((tick) => (
+                            <Line key={tick} x1={GUTTER} x2={width - PAD_RIGHT} y1={yAt(tick)} y2={yAt(tick)} stroke={colors.grid} strokeWidth={1} />
+                        ))}
+                        {area ? <Path d={area} fill={colors.line} fillOpacity={0.15} /> : null}
+                        {solid ? <Path d={solid} stroke={colors.line} strokeWidth={2} fill="none" strokeLinecap="round" strokeLinejoin="round" /> : null}
+                        {dashed ? <Path d={dashed} stroke={colors.line} strokeWidth={2} fill="none" strokeDasharray="4 4" strokeLinecap="round" /> : null}
+                        {points.length === 1 && first ? <Circle cx={first.x} cy={first.y} r={3} fill={colors.line} /> : null}
+                        {activePoint ? (
+                            <>
+                                <Line x1={activePoint.x} x2={activePoint.x} y1={PAD_TOP} y2={baseline} stroke={colors.guide} strokeWidth={1} strokeDasharray="2 3" />
+                                <Circle cx={activePoint.x} cy={activePoint.y} r={4} fill={colors.line} />
+                            </>
+                        ) : null}
+                    </Svg>
+                ) : null}
+                {width > 0 ? ticks.map((tick) => (
+                    <Text
+                        key={tick}
+                        className="absolute text-right text-xs text-muted-foreground"
+                        style={{ left: 0, width: GUTTER - 8, top: yAt(tick) - 8 }}
+                        numberOfLines={1}
+                    >
+                        {formatAxis(metric, tick, strings)}
+                    </Text>
+                )) : null}
+                {width > 0 ? labels.map((label, i) => (i % labelEvery ? null : (
+                    <Text
+                        key={i}
+                        className="absolute text-center text-xs text-muted-foreground"
+                        style={{
+                            left: Math.min(Math.max(0, xAt(i) - X_LABEL_WIDTH / 2), width - X_LABEL_WIDTH),
+                            width: X_LABEL_WIDTH,
+                            top: baseline + 4,
+                        }}
+                        numberOfLines={1}
+                    >
+                        {label}
+                    </Text>
+                ))) : null}
+                <View
+                    className="absolute inset-0"
+                    accessibilityElementsHidden
+                    importantForAccessibility="no-hide-descendants"
+                    {...pointerProps}
+                />
+            </View>
+        </View>
+    );
+}

+ 97 - 0
neo/packages/app/components/elements/analytics/filters.tsx

@@ -0,0 +1,97 @@
+import { View, Row } from 'app/design/view';
+import { Text } from 'app/design/typography';
+import { NeoButton } from 'app/design/controls';
+import DropdownMenu from 'app/ui/atoms/dropdown-menu';
+import { BlockWrapper } from 'app/components/block-wrapper';
+import { cn, isWeb } from 'app/lib/util';
+import type { AnalyticsElementProps, AnalyticsOption, FiltersReport } from './analytics.types';
+import { useAnalyticsFilters, useAnalyticsReport, getStrings } from './use-analytics';
+import { fillPlaceholder, formatNumber } from './format';
+
+/** Menu id of the empty value ("All platforms"). */
+const ALL = '_all';
+
+function OptionPicker({ label, options, value, icon, onChange }: {
+    label: string;
+    options: AnalyticsOption[];
+    value: string;
+    icon: string;
+    onChange: (value: string) => void;
+}) {
+    const current = options.find((option) => option.value === value) ?? options[0];
+    if (!current) return null;
+
+    return (
+        <DropdownMenu
+            items={options.map((option) => ({
+                id: option.value || ALL,
+                key: option.value || ALL,
+                name: option.value || ALL,
+                title: option.title,
+                selected: option.value === current.value,
+            }))}
+            onSelect={(item) => onChange(item.id === ALL ? '' : String(item.id ?? ''))}
+            triggerAccessibilityLabel={`${label}: ${current.title}`}
+        >
+            <NeoButton
+                label={current.title}
+                style="bordered"
+                image={icon}
+                accessibilityLabel={`${label}: ${current.title}`}
+                interactive
+            />
+        </DropdownMenu>
+    );
+}
+
+/** Period and platform pickers, active dimension filters and visitors online. */
+export default function AnalyticsFilters({ data, blockWrapperProps }: AnalyticsElementProps) {
+    const strings = getStrings(data);
+    const { filters, setRange, setPlatform, removeFilter } = useAnalyticsFilters();
+    const { report, hidden, isUpdating } = useAnalyticsReport<FiltersReport>(data);
+
+    if (hidden) return null;
+
+    const ranges = data?.ranges ?? [];
+    const platforms: AnalyticsOption[] = [{ value: '', title: strings.all_platforms }, ...(data?.platforms ?? [])];
+    // Chips follow the filter state, so they change at once; the report (which can still be the
+    // previous filters' while the new one loads) only gives them readable labels.
+    const chips = Object.entries(filters.f).map(([name, value]) =>
+        report?.chips.find((chip) => chip.name === name && chip.value === value) ?? { name, value, title: name, label: value });
+
+    return (
+        <BlockWrapper {...blockWrapperProps}>
+            <Row className="w-full flex-wrap items-center gap-2">
+                <OptionPicker label={strings.range} options={ranges} value={filters.range} icon="Calendar" onChange={setRange} />
+                <OptionPicker label={strings.platform} options={platforms} value={filters.platform} icon="Smartphone" onChange={setPlatform} />
+                {chips.map((chip) => (
+                    <NeoButton
+                        key={chip.name}
+                        style="bordered"
+                        controlSize="small"
+                        label={`${chip.title}: ${chip.label}`}
+                        image="X"
+                        imagePlacement="trailing"
+                        accessibilityLabel={fillPlaceholder(strings.remove_filter, `${chip.title}: ${chip.label}`)}
+                        expoUI={false}
+                        onPress={() => removeFilter(chip.name)}
+                    />
+                ))}
+                {report ? (
+                    <Row
+                        className={cn('ms-auto items-center gap-2 py-2', isUpdating && 'opacity-60')}
+                        // One stop on native; web can't name a div without a role and reads the texts.
+                        {...(isWeb ? {} : {
+                            accessible: true,
+                            accessibilityLabel: `${formatNumber(report.online)} ${fillPlaceholder(strings.online, report.window)}`,
+                        })}
+                    >
+                        <View className="h-2 w-2 rounded-full bg-green" />
+                        <Text className="text-sm font-semibold text-foreground">{formatNumber(report.online)}</Text>
+                        <Text className="text-sm text-muted-foreground">{fillPlaceholder(strings.online, report.window)}</Text>
+                    </Row>
+                ) : null}
+            </Row>
+        </BlockWrapper>
+    );
+}

+ 98 - 0
neo/packages/app/components/elements/analytics/format.ts

@@ -0,0 +1,98 @@
+// Number, percent and duration formatting as the classic UI does it
+// (BxAnalyticsTemplate::formatValue and js/dashboard.js formatMetric).
+// Hand-written instead of Intl, so web, iOS and Android print the same.
+import type { AnalyticsStrings } from './analytics.types';
+
+type DurationStrings = Pick<AnalyticsStrings, 'duration_h' | 'duration_m' | 'duration_s'>;
+
+const COMPACT: ReadonlyArray<readonly [number, string]> = [
+    [1e9, 'B'],
+    [1e6, 'M'],
+    [1e3, 'K'],
+];
+
+function groupThousands(value: number): string {
+    return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+}
+
+function trimZeros(value: string): string {
+    return value.includes('.') ? value.replace(/\.?0+$/, '') : value;
+}
+
+/** 950, 9.5K, 95K, 1.2M */
+export function formatNumber(value: unknown): string {
+    const n = Math.trunc(Number(value) || 0);
+    for (const [size, suffix] of COMPACT) {
+        if (n >= size) {
+            const f = n / size;
+            return (f < 10 ? trimZeros(f.toFixed(1)) : groupThousands(Math.floor(f))) + suffix;
+        }
+    }
+    return String(n);
+}
+
+export function formatPercent(value: unknown): string {
+    const n = Number(value) || 0;
+    return (Math.abs(n) < 10 && Math.round(n) !== n ? n.toFixed(1) : String(Math.round(n))) + '%';
+}
+
+export function formatDuration(value: unknown, strings: DurationStrings): string {
+    const s = Math.round(Number(value) || 0);
+    if (s < 60) return s + strings.duration_s;
+    if (s < 3600) return Math.floor(s / 60) + strings.duration_m + ' ' + (s % 60) + strings.duration_s;
+    return Math.floor(s / 3600) + strings.duration_h + ' ' + Math.floor((s % 3600) / 60) + strings.duration_m;
+}
+
+/** A metric value for a key figure or a list. */
+export function formatMetric(metric: string, value: unknown, strings: DurationStrings): string {
+    if (value === null || value === undefined || value === '') return '';
+    switch (metric) {
+        case 'bounce_rate':
+            return formatPercent(value);
+        case 'pages_per_session':
+            return (Number(value) || 0).toFixed(1);
+        case 'duration':
+            return formatDuration(value, strings);
+        default:
+            return formatNumber(value);
+    }
+}
+
+/** A chart axis tick (the classic chart rounds rates to whole percent). */
+export function formatAxis(metric: string, value: number, strings: DurationStrings): string {
+    if (metric === 'bounce_rate') return Math.round(value) + '%';
+    return formatMetric(metric, value, strings);
+}
+
+/** +12%, -3.4%, '' when there is nothing to compare with. */
+export function formatChange(change: number | null | undefined): string {
+    if (change === null || change === undefined) return '';
+    const small = Math.abs(change) < 10;
+    const text = small ? change.toFixed(1) : groupThousands(Math.abs(Math.round(change)));
+    const sign = change > 0 ? '+' : (!small && change < 0 ? '-' : '');
+    return sign + text + '%';
+}
+
+export type Trend = 'good' | 'bad' | 'flat';
+
+/** Down is good for the bounce rate and errors (BxAnalyticsTemplate::_trend). */
+export function getTrend(metric: string, change: number | null | undefined): Trend {
+    if (change === null || change === undefined || change === 0) return 'flat';
+    const up = change > 0;
+    if (metric === 'bounce_rate' || metric === 'errors') return up ? 'bad' : 'good';
+    return up ? 'good' : 'bad';
+}
+
+/** Fill UNA's `{0}` placeholder (a replacer function, so `$&` in a page title stays as is). */
+export function fillPlaceholder(text: string, value: string | number): string {
+    return text.replace('{0}', () => String(value));
+}
+
+/** The device's IANA timezone, '' when the runtime can't tell (UNA then uses UTC). */
+export function getDeviceTimezone(): string {
+    try {
+        return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
+    } catch {
+        return '';
+    }
+}

+ 119 - 0
neo/packages/app/components/elements/analytics/overview.tsx

@@ -0,0 +1,119 @@
+import { useState } from 'react';
+import { View, Pressable } from 'app/design/view';
+import { Text } from 'app/design/typography';
+import { BlockWrapper } from 'app/components/block-wrapper';
+import { cn, isWeb } from 'app/lib/util';
+import type { AnalyticsElementProps, AnalyticsKpi, AnalyticsStrings, OverviewReport } from './analytics.types';
+import { useAnalyticsReport, getStrings } from './use-analytics';
+import { fillPlaceholder, formatChange, formatMetric, getTrend, type Trend } from './format';
+import { AreaChart } from './area-chart';
+import { AnalyticsEmpty, AnalyticsError, AnalyticsSkeleton } from './states';
+
+const TREND_TEXT: Record<Trend, string> = {
+    good: 'text-emerald-700 dark:text-emerald-300',
+    bad: 'text-rose-700 dark:text-rose-300',
+    flat: 'text-muted-foreground',
+};
+
+function KpiTile({ metric, title, kpi, selected, strings, onPress }: {
+    metric: string;
+    title: string;
+    kpi: AnalyticsKpi;
+    selected: boolean;
+    strings: AnalyticsStrings;
+    onPress: () => void;
+}) {
+    const value = formatMetric(metric, kpi.value, strings);
+    const change = formatChange(kpi.change);
+    const previous = fillPlaceholder(strings.previous_period, formatMetric(metric, kpi.previous, strings));
+    const label = [`${title}: ${value}`, change, previous].filter(Boolean).join(', ');
+
+    return (
+        <View className="w-1/2 p-1 sm:w-1/3 lg:w-1/6">
+            <Pressable
+                onPress={onPress}
+                accessibilityRole="button"
+                accessibilityLabel={label}
+                accessibilityState={{ selected }}
+                // Web drops accessibilityState: a toggle, as in the classic UI.
+                {...(isWeb ? { 'aria-pressed': selected, title: previous } : {})}
+                className={cn(
+                    'min-h-11 w-full gap-1 rounded-xl border-2 px-3 py-2',
+                    selected ? 'border-primary bg-primary/10' : 'border-transparent bg-muted-foreground/10 web:hover:bg-muted-foreground/20'
+                )}
+            >
+                <Text className="text-xs text-muted-foreground" numberOfLines={1}>{title}</Text>
+                <View className="flex-row flex-wrap items-baseline gap-x-2">
+                    <Text className="text-xl font-semibold text-foreground">{value}</Text>
+                    {change ? (
+                        <Text className={cn('text-xs font-medium', TREND_TEXT[getTrend(metric, kpi.change)])}>{change}</Text>
+                    ) : null}
+                </View>
+            </Pressable>
+        </View>
+    );
+}
+
+/** Key figures with the change against the previous period, and the selected one over time. */
+export default function AnalyticsOverview({ data, blockWrapperProps }: AnalyticsElementProps) {
+    const strings = getStrings(data);
+    const { report, hidden, isLoading, isError, isUpdating, refetch } = useAnalyticsReport<OverviewReport>(data);
+    const [metric, setMetric] = useState('visitors');
+
+    if (hidden) return null;
+
+    const titles = data?.metrics ?? {};
+    const kpis = report?.kpis ?? {};
+    const metrics = Object.keys(kpis);
+    const selected = kpis[metric] ? metric : (metrics[0] ?? 'visitors');
+    const series = Array.isArray(report?.series) ? report.series : [];
+    const hasData = series.some((point) => !point.future && Number(point.visitors) > 0) || Number(kpis.visitors?.value) > 0;
+
+    let content;
+    if (isLoading) {
+        content = (
+            <View className="w-full gap-4">
+                <AnalyticsSkeleton rows={2} />
+                <View className="h-48 w-full rounded-xl bg-muted" />
+            </View>
+        );
+    } else if (!report) {
+        content = isError ? <AnalyticsError text={strings.error} onRetry={refetch} /> : null;
+    } else {
+        content = (
+            <View className={cn('w-full gap-4', isUpdating && 'opacity-60')} aria-busy={isUpdating}>
+                <View className="-m-1 flex-row flex-wrap" role="group" accessibilityLabel={strings.key_figures}>
+                    {metrics.map((name) => {
+                        const kpi = kpis[name];
+                        return kpi ? (
+                            <KpiTile
+                                key={name}
+                                metric={name}
+                                title={titles[name] ?? name}
+                                kpi={kpi}
+                                selected={name === selected}
+                                strings={strings}
+                                onPress={() => setMetric(name)}
+                            />
+                        ) : null;
+                    })}
+                </View>
+                {hasData ? (
+                    <AreaChart
+                        series={series}
+                        metric={selected}
+                        metricTitle={titles[selected] ?? selected}
+                        bucket={report.bucket}
+                        tz={report.tz}
+                        strings={strings}
+                    />
+                ) : (
+                    <AnalyticsEmpty text={strings.no_data} />
+                )}
+                {isError ? <AnalyticsError text={strings.error} onRetry={refetch} /> : null}
+            </View>
+        );
+    }
+
+    return <BlockWrapper {...blockWrapperProps}>{content}</BlockWrapper>;
+}

+ 34 - 0
neo/packages/app/components/elements/analytics/states.tsx

@@ -0,0 +1,34 @@
+import { useTranslation } from 'react-i18next';
+import { View } from 'app/design/view';
+import { Text } from 'app/design/typography';
+import { NeoButton } from 'app/design/controls';
+import { cn } from 'app/lib/util';
+
+/** Grey placeholder bars while a report loads for the first time. */
+export function AnalyticsSkeleton({ rows = 3, className }: { rows?: number; className?: string }) {
+    return (
+        <View className={cn('w-full gap-3', className)} accessibilityElementsHidden importantForAccessibility="no-hide-descendants" aria-hidden>
+            {Array.from({ length: rows }, (_, i) => (
+                <View key={i} className={cn('h-5 rounded-md bg-muted', i % 2 ? 'w-2/3' : 'w-full')} />
+            ))}
+        </View>
+    );
+}
+
+export function AnalyticsError({ text, onRetry }: { text: string; onRetry: () => void }) {
+    const { t } = useTranslation();
+    return (
+        <View className="w-full items-center gap-3 py-4" accessibilityLiveRegion="polite">
+            <Text className="text-center text-sm text-muted-foreground" accessibilityRole="alert">{text}</Text>
+            <NeoButton style="bordered" controlSize="regular" label={t('Retry')} image="RotateCw" onPress={() => onRetry()} />
+        </View>
+    );
+}
+
+export function AnalyticsEmpty({ text }: { text: string }) {
+    return (
+        <View className="w-full items-center py-6">
+            <Text className="text-center text-sm text-muted-foreground">{text}</Text>
+        </View>
+    );
+}

+ 118 - 0
neo/packages/app/components/elements/analytics/top.tsx

@@ -0,0 +1,118 @@
+import { useState } from 'react';
+import { View, Row, Pressable } from 'app/design/view';
+import { Text } from 'app/design/typography';
+import Tabs from 'app/ui/molecules/tabs/tabs';
+import { BlockWrapper } from 'app/components/block-wrapper';
+import { cn, isWeb } from 'app/lib/util';
+import type { AnalyticsElementProps, AnalyticsStrings, TopItem, TopReport } from './analytics.types';
+import { useAnalyticsFilters, useAnalyticsReport, getStrings } from './use-analytics';
+import { fillPlaceholder, formatNumber } from './format';
+import { AnalyticsEmpty, AnalyticsError, AnalyticsSkeleton } from './states';
+
+function TopRow({ item, strings, onFilter }: { item: TopItem; strings: AnalyticsStrings; onFilter?: () => void }) {
+    const visitors = formatNumber(item.visitors);
+    const share = `${item.share}%`;
+    // Web reads the row's label instead of the cells (the column header is hidden).
+    const hideOnWeb = isWeb ? { 'aria-hidden': true } : {};
+    const inner = (
+        <>
+            <View
+                className="absolute bottom-0 left-0 top-0 rounded-md bg-primary/10"
+                style={{ width: `${Math.max(0, Math.min(100, Number(item.bar) || 0))}%` }}
+            />
+            <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1} {...hideOnWeb}>{item.label}</Text>
+            <Text className="shrink-0 text-sm font-medium text-foreground" {...hideOnWeb}>{visitors}</Text>
+            <Text className="w-12 shrink-0 text-right text-xs text-muted-foreground" {...hideOnWeb}>{share}</Text>
+        </>
+    );
+    const rowClass = 'relative min-h-11 w-full flex-row items-center gap-3 px-2';
+
+    // Rows without a value (direct visits, "(none)") filter nothing in the classic UI either.
+    if (!onFilter) {
+        const label = `${item.label}: ${visitors} ${strings.visitors}, ${share}`;
+        // A div without a role can't be named on web: the label goes in as hidden text.
+        return (
+            <View className={rowClass} {...(isWeb ? {} : { accessible: true, accessibilityLabel: label })}>
+                {isWeb ? <Text className="sr-only">{label}</Text> : null}
+                {inner}
+            </View>
+        );
+    }
+
+    const filterBy = fillPlaceholder(strings.filter_by, item.label);
+    return (
+        <Pressable
+            onPress={onFilter}
+            accessibilityRole="button"
+            accessibilityLabel={`${filterBy}: ${visitors} ${strings.visitors}, ${share}`}
+            {...(isWeb ? { title: filterBy } : {})}
+            className={cn(rowClass, 'rounded-md web:hover:bg-muted-foreground/10')}
+        >
+            {inner}
+        </Pressable>
+    );
+}
+
+/** A top list (pages, sources, locations, devices, platforms) with a tab per dimension. */
+export default function AnalyticsTop({ data, blockWrapperProps }: AnalyticsElementProps) {
+    const strings = getStrings(data);
+    const { addFilter } = useAnalyticsFilters();
+    const [tab, setTab] = useState('');
+    const { report, hidden, isLoading, isError, isUpdating, refetch } = useAnalyticsReport<TopReport>(data, tab ? { tab } : undefined);
+
+    if (hidden) return null;
+
+    const tabs = Array.isArray(report?.tabs) ? report.tabs : [];
+    const items = Array.isArray(report?.items) ? report.items : [];
+
+    let body;
+    if (isLoading) {
+        body = <AnalyticsSkeleton rows={5} />;
+    } else if (!report) {
+        body = isError ? <AnalyticsError text={strings.error} onRetry={refetch} /> : null;
+    } else if (!items.length) {
+        body = <AnalyticsEmpty text={strings.no_data} />;
+    } else {
+        body = (
+            <View className="w-full">
+                <Row className="w-full items-center gap-3 px-2 pb-1" aria-hidden accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
+                    <Text className="flex-1 text-xs font-medium uppercase text-muted-foreground" numberOfLines={1}>{report.column}</Text>
+                    <Text className="text-xs font-medium uppercase text-muted-foreground" numberOfLines={1}>{strings.visitors}</Text>
+                </Row>
+                <View className="w-full gap-0.5" role="list">
+                    {items.map((item, i) => (
+                        <View key={`${item.value}-${i}`} role="listitem">
+                            <TopRow
+                                item={item}
+                                strings={strings}
+                                onFilter={item.value !== '' ? () => addFilter(report.dimension, item.value) : undefined}
+                            />
+                        </View>
+                    ))}
+                </View>
+                {isError ? <AnalyticsError text={strings.error} onRetry={refetch} /> : null}
+            </View>
+        );
+    }
+
+    return (
+        <BlockWrapper {...blockWrapperProps}>
+            <View className="w-full gap-3">
+                {tabs.length > 1 ? (
+                    <Tabs
+                        tabs={tabs.map((item) => ({ key: item.name, title: item.title }))}
+                        activeTab={tab || report?.tab}
+                        onTabChange={setTab}
+                        overflow="scroll"
+                        hug
+                        equalWidth={false}
+                        triggerClassName="native:min-h-11"
+                    />
+                ) : null}
+                <View className={cn('w-full', isUpdating && 'opacity-60')} aria-busy={isUpdating}>
+                    {body}
+                </View>
+            </View>
+        </BlockWrapper>
+    );
+}

+ 130 - 0
neo/packages/app/components/elements/analytics/use-analytics.ts

@@ -0,0 +1,130 @@
+import { useCallback } from 'react';
+import { atom, useAtom, type PrimitiveAtom } from 'jotai';
+import { useTabChromeKey } from 'app/context/tab-chrome';
+import { useFetch } from 'app/lib/hooks/use-fetch';
+import type { AnalyticsBlockData, AnalyticsFilterState, AnalyticsStrings } from './analytics.types';
+import { getDeviceTimezone } from './format';
+
+const DEFAULT_FILTERS: AnalyticsFilterState = { range: '7d', platform: '', f: {} };
+
+// One filter state per screen: every analytics block on a screen follows the same
+// filter bar, the way the classic page's blocks follow its URL parameters. Native
+// keeps one per tab screen (several stay mounted); web has one screen ('web').
+// Kept out of the URL on purpose: a query string on the page URL makes the
+// Conductor lose the Analytics subtab and refetches the page on the server.
+const filtersAtoms = new Map<string, PrimitiveAtom<AnalyticsFilterState>>();
+
+function getFiltersAtom(key: string): PrimitiveAtom<AnalyticsFilterState> {
+    let filtersAtom = filtersAtoms.get(key);
+    if (!filtersAtom) {
+        filtersAtom = atom<AnalyticsFilterState>(DEFAULT_FILTERS);
+        filtersAtoms.set(key, filtersAtom);
+    }
+    return filtersAtom;
+}
+
+export function useAnalyticsFilters() {
+    const [filters, setFilters] = useAtom(getFiltersAtom(useTabChromeKey()));
+
+    const setRange = useCallback((range: string) => {
+        setFilters((prev) => (prev.range === range ? prev : { ...prev, range }));
+    }, [setFilters]);
+
+    const setPlatform = useCallback((platform: string) => {
+        setFilters((prev) => (prev.platform === platform ? prev : { ...prev, platform }));
+    }, [setFilters]);
+
+    /** Show only rows where `dimension` is `value`; the platform has its own filter. */
+    const addFilter = useCallback((dimension: string, value: string) => {
+        if (dimension === 'platform') {
+            setPlatform(value);
+            return;
+        }
+        setFilters((prev) => (prev.f[dimension] === value ? prev : { ...prev, f: { ...prev.f, [dimension]: value } }));
+    }, [setFilters, setPlatform]);
+
+    const removeFilter = useCallback((dimension: string) => {
+        setFilters((prev) => {
+            if (!(dimension in prev.f)) return prev;
+            const f = { ...prev.f };
+            delete f[dimension];
+            return { ...prev, f };
+        });
+    }, [setFilters]);
+
+    return { filters, setRange, setPlatform, addFilter, removeFilter };
+}
+
+const DEFAULT_STRINGS: AnalyticsStrings = {
+    // Fallbacks for a server that sends no strings; UNA sends them translated.
+    range: 'Period',
+    platform: 'Platform',
+    all_platforms: 'All platforms',
+    online: 'online in the last {0} minutes',
+    remove_filter: 'Remove filter: {0}',
+    filter_by: 'Show only {0}',
+    previous_period: 'Previous period: {0}',
+    key_figures: 'Key figures',
+    visitors: 'Unique visitors',
+    no_data: 'No data for this period',
+    error: "Couldn't load this report.",
+    duration_h: 'h',
+    duration_m: 'm',
+    duration_s: 's',
+};
+
+export function getStrings(data: AnalyticsBlockData | undefined): AnalyticsStrings {
+    return { ...DEFAULT_STRINGS, ...(data?.strings || {}) };
+}
+
+type ReportResponse<T> = {
+    status?: number;
+    code?: number;
+    error?: unknown;
+    data?: T;
+};
+
+/**
+ * A block's report from `get_report`, with the screen's filters and the block's own
+ * params (`local` adds per-block state such as the selected tab).
+ *
+ * `fetcher` doesn't throw on UNA errors: `{error, code}` arrives as a body with
+ * status 500, so the result is checked here. Access denied (403) is `denied`: the
+ * block renders nothing, like the classic block for a viewer without access.
+ */
+export function useAnalyticsReport<T>(data: AnalyticsBlockData | undefined, local?: Record<string, string>) {
+    const { filters } = useAnalyticsFilters();
+
+    const params: Record<string, unknown> = {
+        ...(data?.params && typeof data.params === 'object' ? data.params : {}),
+        ...local,
+        range: filters.range,
+        tz: getDeviceTimezone(),
+    };
+    if (filters.platform) params.platform = filters.platform;
+    if (Object.keys(filters.f).length) params.f = filters.f;
+
+    const url = data?.report && data.request_url
+        ? `/api.php?r=${data.request_url}&params[]=${encodeURIComponent(data.report)}&params[]=${encodeURIComponent(JSON.stringify(params))}`
+        : null;
+
+    const query = useFetch<ReportResponse<T>>(url, { keepPreviousData: true });
+    const response = query.data;
+
+    const denied = response?.code === 403 || response?.status === 403;
+    const report = response && response.status === 200 && !response.error && response.data && typeof response.data === 'object'
+        ? response.data
+        : undefined;
+
+    return {
+        report,
+        /** Render nothing: no access, or a block without an endpoint. */
+        hidden: denied || !url,
+        isError: !denied && (!!query.error || (!!response && !report)),
+        /** Nothing to show yet: the first request is on its way. */
+        isLoading: !!url && !response && !query.error,
+        /** Showing the previous filters' data while the new ones load. */
+        isUpdating: query.isFetching && !!response,
+        refetch: query.refetch,
+    };
+}

+ 5 - 28
neo/packages/app/components/nav/expo-screen.js

@@ -5,8 +5,8 @@ import { appSetting, parseUrl, parseQueryString, isNativeTabsEnabled } from 'app
 import { useBottomSheetData } from 'app/context/bottomsheet';
 import { fetcher } from 'app/lib/fetcher';
 import { useLocalSearchParams, useIsFocused } from 'app/lib/hooks/router'
-import { ensureTabHistory, applyTabHistory, peekTabLastUrl, registerPushedScreen } from 'app/lib/navigation/tab-history';
-import { splitTabBarItems } from 'app/components/nav/tabs/tab-menu';
+import { ensureTabHistory, applyTabHistory, registerPushedScreen } from 'app/lib/navigation/tab-history';
+import { resolveTabPageUrl } from 'app/components/nav/tab-page-url';
 import { getCachedPageData, setCachedPageData } from 'app/lib/cache/native-tab-page-cache';
 import { clearAllPageCache } from 'app/lib/cache/clear-page-cache';
 import emitter, { EVENTS } from 'app/context/emitter'
@@ -115,32 +115,9 @@ function ScreenInner(params) {
     useEffect(() => (pushed ? registerPushedScreen(pathname) : undefined), [pushed, pathname]);
     const { currentUser } = useCurrentUser();
     const activated = useLazyTabActivation();
-    const tabListKey = currentUser ? 'menu_tabbar_logged' : 'menu_tabbar_non_logged';
-    const tabList = appSetting('menu_items', tabListKey) || [];
-    let _path = routeParam(local.url);
-
-    // A native press on the More tab (switching to it or re-tapping it) is
-    // followed by a JUMP_TO that drops this route's params, and the root
-    // fallback below would swap in the first overflow item. Keep the page the
-    // More tab was showing.
-    if (!_path && !pushed && pathname === `/tab${splitTabBarItems(tabList).moreTabIndex}`) {
-        _path = peekTabLastUrl(pathname);
-    }
-
-    // BOTTOM TABS NAVIGATION
-    // Also detect a stale URL from the other user context (e.g. guest's /safety still
-    // held by Expo Router after login). Recompute it from the current context's menu.
-    const otherTabListKey = currentUser ? 'menu_tabbar_non_logged' : 'menu_tabbar_logged';
-    const otherMenuItem = (appSetting('menu_items', otherTabListKey) || []).find((item) => item.key === pathname);
-    const isStaleContextUrl = !!otherMenuItem?.url && _path === otherMenuItem.url;
-
-    if (!pushed && (!_path || _path.includes('/tab') || isStaleContextUrl)) {
-        const item = tabList.find((item) => item.key === pathname);
-        _path = item ? item.url : null;
-        if (_path === '{profile}') {
-            _path = currentUser?.url;
-        }
-    }
+    // The More tab's last page after a params-dropping JUMP_TO, a stale URL from the
+    // other user context and the tab-root fallback: see resolveTabPageUrl.
+    const _path = resolveTabPageUrl(pathname, routeParam(local.url), currentUser, pushed);
 
     const userKey = currentUser?.id
         ? `${currentUser.id}-${currentUser.confirmed}`

+ 49 - 0
neo/packages/app/components/nav/tab-page-url.ts

@@ -0,0 +1,49 @@
+import { appSetting } from 'app/lib/util';
+import { peekTabLastUrl } from 'app/lib/navigation/tab-history';
+import { splitTabBarItems } from 'app/components/nav/tabs/tab-menu';
+
+type TabMenuItem = { key?: string; url?: string };
+type TabUser = { url?: string } | false | null | undefined;
+
+function getMenu(logged: boolean): TabMenuItem[] {
+    return appSetting('menu_items', logged ? 'menu_tabbar_logged' : 'menu_tabbar_non_logged') || [];
+}
+
+/**
+ * The UNA page a tab screen shows (`pagePath` of the native tab Screen): the route's `url`
+ * param, or for a tab root without a usable one, the More tab's last page or the tab's menu
+ * item. Shared with the analytics tracker, so screen views name the page on screen.
+ *
+ * @param tabKey `/tab0`…
+ * @param url the route's `url` param
+ * @param pushed a page pushed onto the tab's stack, not the tab root
+ */
+export function resolveTabPageUrl(tabKey: string, url: string | null | undefined, currentUser: TabUser, pushed: boolean): string | null | undefined {
+    const logged = !!currentUser;
+    const tabList = getMenu(logged);
+    let path = url;
+
+    // A native press on the More tab (switching to it or re-tapping it) is
+    // followed by a JUMP_TO that drops this route's params, and the root
+    // fallback below would swap in the first overflow item. Keep the page the
+    // More tab was showing.
+    if (!path && !pushed && tabKey === `/tab${splitTabBarItems(tabList).moreTabIndex}`) {
+        path = peekTabLastUrl(tabKey);
+    }
+
+    // BOTTOM TABS NAVIGATION
+    // Also detect a stale URL from the other user context (e.g. guest's /safety still
+    // held by Expo Router after login). Recompute it from the current context's menu.
+    const otherMenuItem = getMenu(!logged).find((item) => item.key === tabKey);
+    const isStaleContextUrl = !!otherMenuItem?.url && path === otherMenuItem.url;
+
+    if (!pushed && (!path || path.includes('/tab') || isStaleContextUrl)) {
+        const item = tabList.find((menuItem) => menuItem.key === tabKey);
+        path = item ? item.url : null;
+        if (path === '{profile}') {
+            path = currentUser ? currentUser.url : undefined;
+        }
+    }
+
+    return path;
+}

+ 60 - 0
neo/packages/app/lib/analytics/analytics-tracker.tsx

@@ -0,0 +1,60 @@
+import { useEffect } from 'react';
+import { AppState } from 'react-native';
+import { useGlobalSearchParams, usePathname } from 'expo-router';
+import { useCurrentUserStore } from 'app/context/user';
+import { parseTabKey } from 'app/lib/navigation/tab-path';
+import { resolveTabPageUrl } from 'app/components/nav/tab-page-url';
+import { setTrackerVisible, startTracker, trackPageview } from './tracker';
+
+/** A screen shown for less than this isn't a screen view (web waits as long for the title). */
+const SETTLE_MS = 400;
+
+/** `/view-post?id=5` from a UNA page URL or path (`https://site/view-post?id=5`, `view-post`). */
+function toPagePath(url: string): string {
+    const absolute = /^https?:\/\/[^/]+(\/[^#]*)?/i.exec(url);
+    if (absolute) return absolute[1] || '/';
+    const path = url.split('#')[0] ?? '';
+    return path.startsWith('/') ? path : `/${path}`;
+}
+
+/**
+ * The UNA page a screen shows, resolved as the tab Screen resolves it (`url` param, the More
+ * tab's last page, the tab's menu item). Other routes (`/pg`) are their own path.
+ * `user` is the signed-in profile, `false` for a guest, `null` while unknown.
+ */
+function getScreenPath(pathname: string, url: string | undefined, user: { url?: string } | false | null): string {
+    const tabKey = parseTabKey(pathname);
+    if (!tabKey) return pathname;
+    if (user === null) return '';
+    const page = resolveTabPageUrl(tabKey, url, user, pathname !== tabKey);
+    return page ? toPagePath(page) : '';
+}
+
+/** Sends screen views and engaged time to UNA's analytics. Mounted once, in the tabs layout. */
+export function AnalyticsTracker() {
+    const pathname = usePathname();
+    const params = useGlobalSearchParams<{ url?: string | string[] }>();
+    const url = Array.isArray(params.url) ? params.url[0] : params.url;
+    const user = useCurrentUserStore((store) => store.currentUser);
+    const screenPath = getScreenPath(pathname, url, user);
+
+    useEffect(() => {
+        startTracker(AppState.currentState === 'active');
+        // `inactive` (iOS app switcher, Control Center) passes quickly: only background counts as hidden
+        const subscription = AppState.addEventListener('change', (appState) => {
+            if (appState === 'active') setTrackerVisible(true);
+            else if (appState === 'background') setTrackerVisible(false);
+        });
+        return () => subscription.remove();
+    }, []);
+
+    useEffect(() => {
+        if (!screenPath) return undefined;
+        // A screen left before this fires isn't counted, as on web: the tab root a deep
+        // link lands on just before its page is pushed.
+        const timer = setTimeout(() => trackPageview(screenPath), SETTLE_MS);
+        return () => clearTimeout(timer);
+    }, [screenPath]);
+
+    return null;
+}

+ 45 - 0
neo/packages/app/lib/analytics/analytics-tracker.web.tsx

@@ -0,0 +1,45 @@
+'use client';
+import { Suspense, useEffect } from 'react';
+import { usePathname, useSearchParams } from 'next/navigation';
+import { setTrackerVisible, startTracker, trackPageview } from './tracker';
+
+/** Next sets the new page's title shortly after the route changes. */
+const TITLE_DELAY_MS = 400;
+
+/** A pageview on the first page and after every client-side navigation (including History API URL changes). */
+function RouteTracker() {
+    const pathname = usePathname();
+    const search = useSearchParams()?.toString() ?? '';
+    const route = `${pathname}?${search}`;
+
+    useEffect(() => {
+        if (!route) return undefined;
+        // A page left before this fires (a redirect) isn't counted.
+        const timer = setTimeout(() => trackPageview(window.location.href, document.title), TITLE_DELAY_MS);
+        return () => clearTimeout(timer);
+    }, [route]);
+
+    return null;
+}
+
+/** Sends pageviews and engaged time to UNA's analytics. Mounted once, in the app providers. */
+export function AnalyticsTracker() {
+    useEffect(() => {
+        startTracker(document.visibilityState === 'visible');
+        const onVisibilityChange = () => setTrackerVisible(document.visibilityState === 'visible');
+        const onPageHide = () => setTrackerVisible(false);
+        document.addEventListener('visibilitychange', onVisibilityChange);
+        window.addEventListener('pagehide', onPageHide);
+        return () => {
+            document.removeEventListener('visibilitychange', onVisibilityChange);
+            window.removeEventListener('pagehide', onPageHide);
+        };
+    }, []);
+
+    // useSearchParams needs its own Suspense boundary, or prerendered routes bail out to client rendering.
+    return (
+        <Suspense fallback={null}>
+            <RouteTracker />
+        </Suspense>
+    );
+}

+ 61 - 0
neo/packages/app/lib/analytics/analytics.types.ts

@@ -0,0 +1,61 @@
+/** Client codes of UNA's analytics module (bx_analytics) that NEO sends hits as. */
+export type AnalyticsPlatform = 'next-web' | 'rn-ios' | 'rn-android';
+
+/** Short scalar values; UNA keeps up to 30 keys of `[a-zA-Z0-9_.-]{1,40}`. */
+export type AnalyticsProps = Record<string, string | number | boolean>;
+
+/** One hit of a collect request (BxAnalyticsCollector). */
+export type AnalyticsHit = {
+    type: 'pageview' | 'event' | 'leave';
+    /** Full URL on web, the UNA page path (`/view-post?id=5`) in the apps. */
+    url: string;
+    title?: string;
+    /** Event name, `[letters digits _ . : / - space]`, up to 64 characters. */
+    name?: string;
+    props?: AnalyticsProps;
+    value?: number;
+    /** An event that doesn't count as interaction (the visit stays a bounce). */
+    non_interactive?: 1;
+    /** Leave hits: milliseconds the page was visible. */
+    engaged?: number;
+    /** Milliseconds since the hit happened, for hits sent late. */
+    age?: number;
+};
+
+/** What a client tells about itself with every collect request. */
+export type AnalyticsClient = {
+    platform: AnalyticsPlatform;
+    /** Apps: bundle id / package name and version. */
+    app?: string;
+    app_version?: string;
+    language?: string;
+    /** `1440x900` */
+    screen?: string;
+    /** Apps: system, its version and device type (mobile, tablet); browsers send a user agent. */
+    os?: string;
+    os_version?: string;
+    device?: string;
+};
+
+export type AnalyticsPayload = AnalyticsClient & {
+    referrer?: string;
+    /** Random visitor id, sent in persistent identity mode only. */
+    vid?: string;
+    hits: AnalyticsHit[];
+};
+
+/** `bx_analytics/get_tracker_config`. */
+export type TrackerConfig = {
+    enabled: boolean;
+    persistent: boolean;
+    respect_dnt: boolean;
+    /** Seconds without hits after which UNA starts a new visit. */
+    session_timeout: number;
+};
+
+export type TrackEventOptions = {
+    /** A number to sum in reports (an amount, a score). */
+    value?: number;
+    /** Doesn't make the visit interactive (an automatic event, not a user action). */
+    nonInteractive?: boolean;
+};

+ 73 - 0
neo/packages/app/lib/analytics/client.ts

@@ -0,0 +1,73 @@
+import { Dimensions, Platform } from 'react-native';
+import Constants from 'expo-constants';
+import i18n from 'i18next';
+import type { AnalyticsClient } from './analytics.types';
+
+type ApplicationModule = {
+    applicationId?: string | null;
+    nativeApplicationVersion?: string | null;
+};
+
+// Guarded: binaries built before expo-application was added have no native module.
+let Application: ApplicationModule | null = null;
+try {
+    Application = require('expo-application');
+} catch {
+    Application = null;
+}
+
+let client: AnalyticsClient | null = null;
+
+export function isClientAvailable(): boolean {
+    return true;
+}
+
+/** Device language as a tag UNA accepts (`en`, `pt-BR`, `zh-Hans`), or the app language. */
+function getLanguage(): string {
+    let locale = '';
+    try {
+        locale = Intl.DateTimeFormat().resolvedOptions().locale || '';
+    } catch {
+        locale = '';
+    }
+    const match = /^([a-zA-Z]{2,3})(?:[-_]([a-zA-Z0-9]{2,4}))?/.exec(locale || i18n.language || '');
+    if (!match) return '';
+    return match[2] ? `${match[1]}-${match[2]}` : (match[1] ?? '');
+}
+
+/** App, system and device: the apps have no browser user agent for UNA to read them from. */
+export function getClient(): AnalyticsClient {
+    if (client) return client;
+
+    const screen = Dimensions.get('screen');
+    const ios = Platform.OS === 'ios';
+    const tablet = Platform.OS === 'ios' ? Platform.isPad : Math.min(screen.width, screen.height) >= 600;
+    const osVersion = Platform.OS === 'android' ? String(Platform.constants?.Release ?? '') : String(Platform.Version ?? '');
+    const expoConfig = Constants.expoConfig;
+
+    client = {
+        platform: ios ? 'rn-ios' : 'rn-android',
+        app: Application?.applicationId || (ios ? expoConfig?.ios?.bundleIdentifier : expoConfig?.android?.package) || expoConfig?.slug || '',
+        app_version: Application?.nativeApplicationVersion || expoConfig?.version || '',
+        language: getLanguage(),
+        screen: `${Math.round(screen.width)}x${Math.round(screen.height)}`,
+        os: ios ? (tablet ? 'iPadOS' : 'iOS') : 'Android',
+        os_version: /^[0-9.]+$/.test(osVersion) ? osVersion : '',
+        device: tablet ? 'tablet' : 'mobile',
+    };
+    return client;
+}
+
+/** The path events are sent with before the first screen view. */
+export function getFallbackUrl(): string {
+    return '/';
+}
+
+export function getInitialReferrer(): string {
+    return '';
+}
+
+/** Apps have no Do Not Track signal. */
+export function isTrackingBlocked(_respectDnt: boolean): boolean {
+    return false;
+}

+ 53 - 0
neo/packages/app/lib/analytics/client.web.ts

@@ -0,0 +1,53 @@
+import type { AnalyticsClient } from './analytics.types';
+
+type PrivacyNavigator = Navigator & { globalPrivacyControl?: boolean };
+
+/** False while rendering on the server: nothing is tracked there. */
+export function isClientAvailable(): boolean {
+    return typeof window !== 'undefined' && typeof document !== 'undefined';
+}
+
+export function getClient(): AnalyticsClient {
+    const screen = window.screen;
+    return {
+        platform: 'next-web',
+        language: window.navigator.language || '',
+        screen: screen ? `${screen.width}x${screen.height}` : '',
+    };
+}
+
+/** The URL events are sent with before the first pageview. */
+export function getFallbackUrl(): string {
+    return window.location.href;
+}
+
+/**
+ * The referrer of the first page, only when it is another site: NEO's own host isn't one
+ * of UNA's internal hosts, so a NEO page as referrer would count as a referral.
+ */
+export function getInitialReferrer(): string {
+    const referrer = document.referrer;
+    if (!referrer) return '';
+    try {
+        return new URL(referrer).host !== window.location.host ? referrer : '';
+    } catch {
+        return '';
+    }
+}
+
+/**
+ * Don't track automated browsers, visitors who ask not to be tracked (Do Not Track, Global
+ * Privacy Control) when the site honours it, and a browser excluded by hand with
+ * `localStorage.setItem('bx_analytics_ignore', '1')`, as the classic tracker does.
+ */
+export function isTrackingBlocked(respectDnt: boolean): boolean {
+    const nav = window.navigator as PrivacyNavigator;
+    if (nav.webdriver) return true;
+    const windowDnt = (window as Window & { doNotTrack?: string }).doNotTrack;
+    if (respectDnt && (nav.doNotTrack === '1' || windowDnt === '1' || nav.globalPrivacyControl === true)) return true;
+    try {
+        return window.localStorage.getItem('bx_analytics_ignore') === '1';
+    } catch {
+        return false;
+    }
+}

+ 3 - 0
neo/packages/app/lib/analytics/index.ts

@@ -0,0 +1,3 @@
+export { AnalyticsTracker } from './analytics-tracker';
+export { trackEvent } from './tracker';
+export type { AnalyticsProps, TrackEventOptions } from './analytics.types';

+ 231 - 0
neo/packages/app/lib/analytics/tracker.ts

@@ -0,0 +1,231 @@
+import { fetcher } from 'app/lib/fetcher';
+import { persistedGet, persistedSet } from 'app/lib/util/storage';
+import { getClient, getFallbackUrl, getInitialReferrer, isClientAvailable, isTrackingBlocked } from './client';
+import type { AnalyticsHit, AnalyticsPayload, AnalyticsProps, TrackerConfig, TrackEventOptions } from './analytics.types';
+
+/**
+ * Pageviews (screen views), engaged time and custom events for UNA's analytics module
+ * (bx_analytics). The same model as the classic UI's tracker (modules/boonex/analytics/js/
+ * tracker.js): a pageview on every page, a `leave` hit with the visible time when the page
+ * is left or hidden, events on demand.
+ *
+ * Settings come once per app start from `bx_analytics/get_tracker_config`. When the module
+ * is missing, tracking is off for this client, or UNA answers a hit with an error, nothing
+ * more is sent until the app (page) reloads. Failures are silent and nothing here throws.
+ */
+
+const CONFIG_URL = '/api.php?r=bx_analytics/get_tracker_config';
+const COLLECT_URL = '/api.php?r=bx_analytics/collect';
+/** BxAnalyticsConfig MAX_HITS_PER_REQUEST */
+const MAX_HITS = 20;
+/** UNA ignores `age` from an hour on. */
+const MAX_AGE_MS = 3600 * 1000;
+/** After a network failure, wait this long before asking for the settings again. */
+const CONFIG_RETRY_MS = 60 * 1000;
+const REQUEST_TIMEOUT_MS = 10 * 1000;
+const VISITOR_ID_KEY = 'analytics_vid';
+
+type State = 'idle' | 'loading' | 'on' | 'off';
+type QueuedHit = { hit: AnalyticsHit; at: number };
+
+let state: State = 'idle';
+let config: TrackerConfig | null = null;
+let configRetryAt = 0;
+let pending: QueuedHit[] = [];
+let visitorId = '';
+let referrer: string | null = null;
+
+let started = false;
+let currentUrl = '';
+let currentTitle = '';
+/** When the page became visible (0 while hidden), and visible time collected before that. */
+let visibleSince = 0;
+let engagedMs = 0;
+let hiddenAt = 0;
+
+function randomId(): string {
+    const cryptoApi = (globalThis as { crypto?: Crypto }).crypto;
+    if (!cryptoApi?.getRandomValues) return '';
+    const bytes = new Uint8Array(16);
+    cryptoApi.getRandomValues(bytes);
+    return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
+}
+
+/** The visitor id kept on this device, for sites in persistent identity mode. */
+async function getVisitorId(): Promise<string> {
+    const stored = await persistedGet(VISITOR_ID_KEY);
+    if (typeof stored === 'string' && /^[a-f0-9]{32}$/.test(stored)) return stored;
+    const id = randomId();
+    if (id) await persistedSet(VISITOR_ID_KEY, id);
+    return id;
+}
+
+function turnOff(): void {
+    state = 'off';
+    pending = [];
+}
+
+async function loadConfig(): Promise<void> {
+    state = 'loading';
+    try {
+        const response = await fetcher(
+            `${CONFIG_URL}&params[]=${encodeURIComponent(getClient().platform)}`,
+            false,
+            { silent: true, maxAttempts: 1, timeoutMs: REQUEST_TIMEOUT_MS },
+        );
+        const data = response?.status === 200 ? response.data : null;
+        if (!data || !data.enabled || isTrackingBlocked(!!data.respect_dnt)) {
+            turnOff();
+            return;
+        }
+        config = {
+            enabled: true,
+            persistent: !!data.persistent,
+            respect_dnt: !!data.respect_dnt,
+            session_timeout: Number(data.session_timeout) > 0 ? Number(data.session_timeout) : 1800,
+        };
+        try {
+            visitorId = config.persistent ? await getVisitorId() : '';
+        } catch {
+            // storage blocked (web throws SecurityError): track without a visitor id
+            visitorId = '';
+        }
+        state = 'on';
+    } catch {
+        // offline or timed out: keep the queued hits and try again later
+        state = 'idle';
+        configRetryAt = Date.now() + CONFIG_RETRY_MS;
+        return;
+    }
+
+    const now = Date.now();
+    const queued = pending
+        .filter((item) => now - item.at < MAX_AGE_MS)
+        .map(({ hit, at }) => (now - at > 0 ? { ...hit, age: now - at } : hit));
+    pending = [];
+    if (queued.length) void post(queued, false);
+}
+
+async function post(hits: AnalyticsHit[], keepalive: boolean): Promise<void> {
+    const payload: AnalyticsPayload = { ...getClient(), hits: hits.slice(-MAX_HITS) };
+    if (referrer) payload.referrer = referrer;
+    if (visitorId) payload.vid = visitorId;
+    // UNA reads the referrer for a new visit only: the first pageview's is enough
+    if (hits.some((hit) => hit.type === 'pageview')) referrer = '';
+
+    try {
+        const response = await fetcher(
+            [COLLECT_URL, null, JSON.stringify(payload)],
+            false,
+            { silent: true, maxAttempts: 1, timeoutMs: REQUEST_TIMEOUT_MS, keepalive },
+        );
+        if (response?.status !== 200) turnOff();
+    } catch {
+        // a lost hit: not worth a retry
+    }
+}
+
+function submit(hits: AnalyticsHit[], keepalive = false): void {
+    if (!hits.length || state === 'off' || !isClientAvailable()) return;
+
+    if (state === 'on') {
+        void post(hits, keepalive);
+        return;
+    }
+
+    const now = Date.now();
+    pending = pending.concat(hits.map((hit) => ({ hit, at: now }))).slice(-MAX_HITS);
+    if (state === 'idle' && now >= configRetryAt) void loadConfig();
+}
+
+/** The visible time on the current page since the last leave hit, as a hit, or null. */
+function takeLeaveHit(): AnalyticsHit | null {
+    const now = Date.now();
+    const ms = engagedMs + (visibleSince ? now - visibleSince : 0);
+    engagedMs = 0;
+    visibleSince = visibleSince ? now : 0;
+    return ms > 0 && currentUrl ? { type: 'leave', url: currentUrl, engaged: ms } : null;
+}
+
+/**
+ * Start tracking: load the settings and start counting visible time. Called once by
+ * `AnalyticsTracker` at the app root; later calls do nothing.
+ */
+export function startTracker(visible: boolean): void {
+    if (started || !isClientAvailable()) return;
+    started = true;
+    referrer = getInitialReferrer();
+    visibleSince = visible ? Date.now() : 0;
+    if (state === 'idle') void loadConfig();
+}
+
+/**
+ * A page (web) or screen (apps) is shown. The visible time on the previous one is sent
+ * with it. The same URL twice in a row counts once.
+ */
+export function trackPageview(url: string, title = '', force = false): void {
+    if (!url || (url === currentUrl && !force)) return;
+
+    const hits: AnalyticsHit[] = [];
+    const leave = takeLeaveHit();
+    if (leave) hits.push(leave);
+
+    currentUrl = url;
+    currentTitle = title;
+    hits.push(title ? { type: 'pageview', url, title } : { type: 'pageview', url });
+    submit(hits);
+}
+
+/**
+ * The page or app became visible or hidden (web: tab switch, page hide; apps: background).
+ * Hiding sends the visible time. Coming back after a pause longer than UNA's visit timeout
+ * shows the current page again, so the new visit has an entry page.
+ */
+export function setTrackerVisible(visible: boolean): void {
+    if (!started) return;
+    const now = Date.now();
+
+    if (!visible) {
+        if (!visibleSince) return;
+        const leave = takeLeaveHit();
+        visibleSince = 0;
+        hiddenAt = now;
+        if (leave) submit([leave], true);
+        return;
+    }
+
+    if (visibleSince) return;
+    visibleSince = now;
+    const away = hiddenAt ? now - hiddenAt : 0;
+    hiddenAt = 0;
+    if (currentUrl && config && away > config.session_timeout * 1000) {
+        engagedMs = 0;
+        trackPageview(currentUrl, currentTitle, true);
+    }
+}
+
+/**
+ * Send a custom event to UNA's analytics, e.g. `trackEvent('signup_click', { plan: 'pro' })`.
+ * Safe anywhere: does nothing while rendering on the server, when analytics is off, or
+ * before the settings are known (then the event waits for them).
+ *
+ * @param name letters, digits, spaces and `_ . : / -`, up to 64 characters
+ * @param props short scalar values (up to 30 keys)
+ */
+export function trackEvent(name: string, props?: AnalyticsProps, options?: TrackEventOptions): void {
+    if (!isClientAvailable()) return;
+    const eventName = String(name ?? '').trim().slice(0, 64);
+    if (!eventName) return;
+
+    const hit: AnalyticsHit = { type: 'event', name: eventName, url: currentUrl || getFallbackUrl() };
+    if (props && typeof props === 'object') {
+        const scalars: AnalyticsProps = {};
+        for (const [key, value] of Object.entries(props)) {
+            if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') scalars[key] = value;
+        }
+        if (Object.keys(scalars).length) hit.props = scalars;
+    }
+    if (typeof options?.value === 'number' && Number.isFinite(options.value)) hit.value = options.value;
+    if (options?.nonInteractive) hit.non_interactive = 1;
+    submit([hit]);
+}

+ 24 - 2
neo/packages/app/lib/fetcher.ts

@@ -33,8 +33,24 @@ export type FetchOptions = {
     silent?: boolean;
     /** Report request body progress (0..1); sends over XMLHttpRequest instead of `fetch`. */
     onUploadProgress?: (fraction: number) => void;
+    /** Let the request outlive the page (`fetch` keepalive), e.g. a hit sent as the page is hidden. */
+    keepalive?: boolean;
 };
 
+/**
+ * Which NEO client sends a request (`X-Neo-Platform`: web, ios, android), so UNA records
+ * site actions (sign-ups, posts, comments) with the right platform. Native always sends it.
+ * Web sends it only when the request stays on its own origin (the `/api` proxy) or runs on
+ * the server: in a cross-origin browser request a custom header needs a CORS preflight,
+ * which older UNA versions don't allow for this header.
+ */
+function neoPlatformHeader(host: string): string | null {
+    if (Platform.OS === 'ios' || Platform.OS === 'android') return Platform.OS;
+    if (Platform.OS !== 'web') return null;
+    if (typeof window === 'undefined') return 'web';
+    return host.startsWith(window.location.origin) ? 'web' : null;
+}
+
 type AbortErrorLike = Error & { aborted?: boolean };
 
 const USE_PROXY_WEB = appSetting('config', 'use_proxy_web'); 
@@ -71,13 +87,14 @@ export async function fetcher (mixed: FetcherRequest, useProxy = false, fetchOpt
 
 async function fetcherRawOnce(
     url: string,
-    { data, headers, callback, signal, timeoutMs, onUploadProgress }: {
+    { data, headers, callback, signal, timeoutMs, onUploadProgress, keepalive }: {
         data?: BodyInit | null;
         headers: Record<string, string>;
         callback?: (response: Response) => void;
         signal?: AbortSignal;
         timeoutMs?: number;
         onUploadProgress?: (fraction: number) => void;
+        keepalive?: boolean;
     },
 ): Promise<Response> {
     const controller = new AbortController();
@@ -109,6 +126,7 @@ async function fetcherRawOnce(
                 cache: 'no-store',
                 credentials: 'include', // Set to true on UNA side - Access-Control-Allow-Credentials
                 signal: controller.signal,
+                ...(keepalive ? { keepalive: true } : null),
             });
         if (callback)
             callback(r);
@@ -155,6 +173,9 @@ export async function fetcherRaw(host: string, mixed: FetcherRequest, fetchOptio
         headers['Origin'] = origin;
     else if ('web' !== Platform.OS)
         headers['Origin'] = APP_ORIGIN;
+    const neoPlatform = neoPlatformHeader(host);
+    if (neoPlatform && !headers['X-Neo-Platform'])
+        headers['X-Neo-Platform'] = neoPlatform;
 
    // headers['Content-Type'] = "application/json";
     headers['Cache-Control'] = "no-cache";
@@ -170,6 +191,7 @@ export async function fetcherRaw(host: string, mixed: FetcherRequest, fetchOptio
         maxAttempts: maxAttemptsOverride,
         silent = false,
         onUploadProgress,
+        keepalive,
     } = fetchOptions;
     const maxAttempts = maxAttemptsOverride ?? (data ? 1 : 3);
     const baseDelayMs = 300;
@@ -183,7 +205,7 @@ export async function fetcherRaw(host: string, mixed: FetcherRequest, fetchOptio
             throw err;
         }
         try {
-            return await fetcherRawOnce(url, { data, headers, callback, signal, timeoutMs, onUploadProgress });
+            return await fetcherRawOnce(url, { data, headers, callback, signal, timeoutMs, onUploadProgress, keepalive });
         } catch (error: any) {
             lastError = error;
             if (error?.aborted) {

+ 26 - 0
tests/integration/analytics/AnalyticsCollectorTest.php

@@ -115,6 +115,32 @@ class AnalyticsCollectorTest extends BxDolIntegrationTestCase
         $this->assertSame(['rn-android', 'com.neo.so', '1.5.0', 'Android', '15', 'tablet', 'com.neo.so'], [$a['platform'], $a['app'], $a['app_version'], $a['os'], $a['os_version'], $a['device'], $a['hostname']]);
     }
 
+    public function testTrackerConfig()
+    {
+        $this->assertTrue($this->_oModule->serviceIsSafeService('get_tracker_config'), 'NEO reads it over the API');
+
+        $a = $this->_oModule->serviceGetTrackerConfig('rn-ios');
+        $this->assertSame(['enabled', 'persistent', 'respect_dnt', 'session_timeout'], array_keys($a), 'settings only, nothing about the site or the viewer');
+        $this->assertTrue($a['enabled']);
+        $this->assertFalse($a['persistent']);
+        $this->assertTrue($a['respect_dnt']);
+        $this->assertSame($this->_oModule->_oConfig->getSessionTimeout(), $a['session_timeout']);
+
+        $this->assertFalse($this->_oModule->serviceGetTrackerConfig('core')['enabled'], 'the server platform is not for clients');
+        $this->assertFalse($this->_oModule->serviceGetTrackerConfig('windows-phone')['enabled'], 'unknown platform');
+
+        $this->_setOption('bx_analytics_identity', 'persistent');
+        $this->assertTrue($this->_oModule->serviceGetTrackerConfig('next-web')['persistent']);
+
+        $this->_setOption('bx_analytics_platforms', 'next-web');
+        $this->assertFalse($this->_oModule->serviceGetTrackerConfig('rn-ios')['enabled'], 'platform switched off');
+
+        $this->_setOption('bx_analytics_tracking', '');
+        $a = $this->_oModule->serviceGetTrackerConfig('next-web');
+        $this->assertFalse($a['enabled'], 'tracking off');
+        $this->assertFalse($a['persistent'], 'no visitor id to keep when nothing is sent');
+    }
+
     public function testGuestSignsInAndIsForgotten()
     {
         $iProfileId = 987654321;

+ 10 - 0
tests/units/modules/boonex/analytics/classes/BxAnalyticsFilterTest.php

@@ -57,6 +57,16 @@ class BxAnalyticsFilterTest extends \PHPUnit\Framework\TestCase
         $this->assertSame(39600, $a['offset']);
     }
 
+    public function testLegacyTimezoneNameIsAccepted()
+    {
+        // Chrome and Hermes report India as Asia/Calcutta, a backward-compatible name
+        $a = $this->filter(['range' => 'today', 'tz' => 'Asia/Calcutta'])->get();
+
+        $this->assertSame('Asia/Calcutta', $a['tz']);
+        $this->assertSame(19800, $a['offset']);
+        $this->assertSame(gmmktime(18, 30, 0, 10, 12, 2026), $a['from']);
+    }
+
     public function testTwelveMonthsUseCalendarMonths()
     {
         $a = $this->filter(['range' => '12m'])->get();