@harshankur/viewcounter 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.env.example +37 -11
  2. package/README.md +330 -138
  3. package/admin/css/admin.css +891 -198
  4. package/admin/index.html +13 -7
  5. package/admin/js/api.js +52 -6
  6. package/admin/js/appTabs.js +100 -0
  7. package/admin/js/charts.js +529 -189
  8. package/admin/js/constants.js +98 -9
  9. package/admin/js/dataTable.js +478 -0
  10. package/admin/js/format.js +58 -7
  11. package/admin/js/icons.js +168 -0
  12. package/admin/js/listbox.js +2 -1
  13. package/admin/js/logs.js +211 -60
  14. package/admin/js/main.js +201 -37
  15. package/admin/js/overview.js +905 -0
  16. package/admin/js/passwordPrompt.js +75 -0
  17. package/admin/js/table.js +12 -52
  18. package/admin/js/viewDialogs.js +30 -14
  19. package/admin/js/views.js +273 -207
  20. package/admin/locales/en.json +352 -63
  21. package/config/index.js +40 -5
  22. package/constants.js +126 -8
  23. package/db/AdminRepository.js +85 -159
  24. package/db/DatabaseManager.js +57 -7
  25. package/db/LogRepository.js +172 -35
  26. package/db/adminSchema.js +93 -4
  27. package/db/adminSessionStore.js +104 -0
  28. package/db/analysis.js +484 -0
  29. package/db/rejectionCounter.js +117 -0
  30. package/index.js +49 -26
  31. package/middleware/adminAuth.js +83 -43
  32. package/middleware/adminValidation.js +69 -3
  33. package/middleware/auth.js +2 -2
  34. package/middleware/security.js +26 -2
  35. package/middleware/validation.js +50 -2
  36. package/package.json +5 -2
  37. package/routes/admin.js +130 -22
  38. package/routes/analytics.js +236 -18
  39. package/tracker/tracker.js +240 -0
  40. package/utils/appIdUtils.js +1 -1
  41. package/utils/durationUtils.js +33 -0
  42. package/utils/errorUtils.js +4 -1
  43. package/utils/geoCity.js +87 -0
  44. package/utils/ipUtils.js +1 -1
  45. package/utils/privacyUtils.js +2 -2
  46. package/utils/referrerParser.js +23 -5
  47. package/utils/secretStore.js +1 -1
  48. package/utils/userAgentParser.js +52 -3
  49. package/utils/visitorContext.js +70 -0
  50. package/admin/js/insights.js +0 -192
@@ -2,7 +2,7 @@
2
2
  * App ID validation.
3
3
  *
4
4
  * An app ID becomes a MySQL table name. Identifiers cannot be bound as query
5
- * parameters, so they are interpolated — which is safe only because the value
5
+ * parameters, so they are interpolated, which is safe only because the value
6
6
  * is checked here first. App IDs used to come exclusively from local config;
7
7
  * the admin API now accepts them over HTTP, so this is a live injection
8
8
  * boundary, not a formatting preference.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Human-readable durations for configuration: "30m", "12h", "7d".
3
+ *
4
+ * Deliberately small: whole numbers of minutes, hours, or days, nothing else,
5
+ * so a value like "30 min" or "1.5h" is refused rather than read as something
6
+ * the operator did not mean. For a session timeout that difference matters.
7
+ */
8
+
9
+ const UNIT_MS = { m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000 };
10
+ const DURATION = /^(\d{1,6})([mhd])$/;
11
+
12
+ /**
13
+ * @param {string} raw e.g. "7d"
14
+ * @returns {number|null} milliseconds, or null when the value is not a duration
15
+ */
16
+ function parseDuration(raw) {
17
+ const match = DURATION.exec(String(raw ?? '').trim().toLowerCase());
18
+ return match ? Number(match[1]) * UNIT_MS[match[2]] : null;
19
+ }
20
+
21
+ /**
22
+ * The largest whole unit that expresses `ms` exactly: 604800000 -> "7d".
23
+ * @param {number} ms
24
+ * @returns {string}
25
+ */
26
+ function formatDuration(ms) {
27
+ for (const unit of ['d', 'h', 'm']) {
28
+ if (ms % UNIT_MS[unit] === 0) return `${ms / UNIT_MS[unit]}${unit}`;
29
+ }
30
+ return `${ms}ms`;
31
+ }
32
+
33
+ module.exports = { parseDuration, formatDuration };
@@ -6,7 +6,7 @@
6
6
  * identified by an enum member, its message text lives in exactly one table
7
7
  * here, and callers branch on `err.code` rather than parsing message strings.
8
8
  *
9
- * `getError` builds and reports but deliberately does NOT throw — the `throw`
9
+ * `getError` builds and reports but deliberately does NOT throw: the `throw`
10
10
  * stays visible at the call site and under the caller's control.
11
11
  */
12
12
 
@@ -45,6 +45,7 @@ const WarningType = {
45
45
  VIEW_LOG_PRUNE_FAILED: 'VIEW_LOG_PRUNE_FAILED',
46
46
  MIGRATION_TABLE_MISSING: 'MIGRATION_TABLE_MISSING',
47
47
  VIEW_LOG_WRITE_FAILED: 'VIEW_LOG_WRITE_FAILED',
48
+ TRACKING_LOG_WRITE_FAILED: 'TRACKING_LOG_WRITE_FAILED',
48
49
  ADMIN_LOG_WRITE_FAILED: 'ADMIN_LOG_WRITE_FAILED',
49
50
  TRASH_PURGE_FAILED: 'TRASH_PURGE_FAILED',
50
51
  };
@@ -113,6 +114,8 @@ const WARNING_MESSAGES = {
113
114
  'Behind a TLS-terminating proxy, set TRUST_PROXY and have the proxy pass X-Forwarded-Proto and the original Host.',
114
115
  [WarningType.MIGRATION_TABLE_MISSING]: (info) =>
115
116
  `Table '${info?.table}' does not exist; skipping its schema migration.`,
117
+ [WarningType.TRACKING_LOG_WRITE_FAILED]: (info) =>
118
+ `Could not write counted tracking rejections: ${info?.cause}`,
116
119
  [WarningType.VIEW_LOG_WRITE_FAILED]: (info) =>
117
120
  `Could not write the view register log entry for '${info?.appId}': ${info?.cause}`,
118
121
  [WarningType.ADMIN_LOG_WRITE_FAILED]: (info) =>
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Optional region and city for a view, from a city database the operator
3
+ * provides (GEOIP_CITY_DB, an .mmdb file).
4
+ *
5
+ * Off unless configured. The IP is looked up and discarded exactly as for the
6
+ * country: only the region and city names are kept, never the address or any
7
+ * coordinates. Both common free databases are understood, and each carries a
8
+ * licence that asks for a credit, which the admin UI shows:
9
+ * - DB-IP IP to City Lite (CC BY 4.0), https://db-ip.com/db/lite.php
10
+ * - MaxMind GeoLite2 City, https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
11
+ *
12
+ * The file is re-read when it is replaced, so a monthly update needs no restart.
13
+ */
14
+
15
+ const maxmind = require('maxmind');
16
+
17
+ const { FIELD_MAX_LENGTH } = require('../constants');
18
+
19
+ /**
20
+ * The country of every view comes from geoip-country, which bundles MaxMind's
21
+ * GeoLite2 Country data; its licence asks for this credit.
22
+ */
23
+ const COUNTRY_ATTRIBUTION = Object.freeze({
24
+ text: 'This product includes GeoLite2 data created by MaxMind',
25
+ url: 'https://www.maxmind.com',
26
+ });
27
+
28
+ /** The credit each database's licence asks for, by its metadata type. */
29
+ const ATTRIBUTIONS = [
30
+ { match: /dbip|db-ip/i, text: 'IP geolocation by DB-IP', url: 'https://db-ip.com' },
31
+ { match: /geolite2|geoip2/i, text: 'This product includes GeoLite2 data created by MaxMind', url: 'https://www.maxmind.com' },
32
+ ];
33
+
34
+ const englishName = (record) => {
35
+ const name = record?.names?.en;
36
+ return typeof name === 'string' && name.trim() ? name.trim().slice(0, FIELD_MAX_LENGTH.CITY) : null;
37
+ };
38
+
39
+ /**
40
+ * @param {{ get: (ip: string) => object|null, metadata?: { databaseType?: string } }} reader an opened .mmdb reader
41
+ * @returns {{ lookup: (ip: string) => ({ region: string|null, city: string|null }), attribution: object|null, databaseType: string }}
42
+ */
43
+ function createCityLookup(reader) {
44
+ const databaseType = String(reader.metadata?.databaseType || 'unknown');
45
+ const credit = ATTRIBUTIONS.find((candidate) => candidate.match.test(databaseType));
46
+
47
+ return {
48
+ databaseType,
49
+ attribution: credit ? { text: credit.text, url: credit.url } : { text: `Location data: ${databaseType}`, url: null },
50
+
51
+ lookup(ip) {
52
+ let record = null;
53
+ try {
54
+ record = reader.get(ip);
55
+ } catch {
56
+ return { region: null, city: null };
57
+ }
58
+ return {
59
+ region: englishName(record?.subdivisions?.[0]),
60
+ city: englishName(record?.city),
61
+ };
62
+ },
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Open the configured city database, or return null when none is configured.
68
+ * @param {string|undefined} file
69
+ * @returns {Promise<ReturnType<typeof createCityLookup>|null>}
70
+ */
71
+ async function openCityLookup(file) {
72
+ if (!file) return null;
73
+ const reader = await maxmind.open(file, { watchForUpdates: true, watchForUpdatesNonPersistent: true });
74
+ return createCityLookup(reader);
75
+ }
76
+
77
+ /**
78
+ * Every credit the location data in use asks for, each once.
79
+ * @param {{ attribution: object }|null} cityLookup
80
+ * @returns {{ text: string, url: string|null }[]}
81
+ */
82
+ function attributions(cityLookup) {
83
+ const all = [COUNTRY_ATTRIBUTION, ...(cityLookup ? [cityLookup.attribution] : [])];
84
+ return all.filter((credit, index) => all.findIndex((other) => other.text === credit.text) === index);
85
+ }
86
+
87
+ module.exports = { createCityLookup, openCityLookup, attributions, ATTRIBUTIONS, COUNTRY_ATTRIBUTION };
package/utils/ipUtils.js CHANGED
@@ -29,7 +29,7 @@ function isValidIP(ip) {
29
29
  * Deliberately reads ONLY `req.ip`, which Express derives according to the
30
30
  * app's `trust proxy` setting. The previous implementation read `x-real-ip`
31
31
  * and `x-forwarded-for` straight off the request, so any caller could name
32
- * their own address — forging geolocation, inflating unique-visitor counts,
32
+ * their own address: forging geolocation, inflating unique-visitor counts,
33
33
  * and rotating the rate-limiter key to bypass it entirely.
34
34
  *
35
35
  * If a reverse proxy in front of this service sets only `X-Real-IP`, configure
@@ -69,8 +69,8 @@ class PrivacyUtils {
69
69
  * Generate the transient visitor identifier.
70
70
  *
71
71
  * This is a keyed HMAC, not a bare digest. Every non-secret input is
72
- * public or guessable — the date is known, user agents come from a small
73
- * population, and IPv4 is only 2^32 — so an unkeyed SHA-256 of them is
72
+ * public or guessable (the date is known, user agents come from a small
73
+ * population, and IPv4 is only 2^32), so an unkeyed SHA-256 of them is
74
74
  * reversible by exhaustive search in about an hour on one CPU core. The
75
75
  * server secret is what makes that search infeasible; the window id is
76
76
  * what stops hashes being linkable over time.
@@ -7,12 +7,27 @@ const { truncate } = require('./stringUtils');
7
7
  * Parse referrer URLs to extract domain and source type
8
8
  */
9
9
  class ReferrerParser {
10
+ /**
11
+ * A referrer as it may be stored: without its query string or fragment,
12
+ * which can carry tokens, email addresses, or IDs from the page before
13
+ * (a password-reset link, say). The source is read from the whole URL,
14
+ * in memory, before this.
15
+ * @param {string} referrer
16
+ * @returns {string}
17
+ */
18
+ static withoutQuery(referrer) {
19
+ return String(referrer).split(/[?#]/, 1)[0];
20
+ }
21
+
10
22
  /**
11
23
  * Parse referrer URL
12
- * @param {string} referrer - Referrer URL from request headers
24
+ * @param {string} referrer - the referrer the page reported (never the request's Referer header)
25
+ * @param {string|null} [siteHostname] - the hostname of the page viewed; a
26
+ * referrer on the same site (www. or not) is internal navigation, which
27
+ * would otherwise count every click within a site as a referral from it
13
28
  * @returns {object} Parsed referrer data
14
29
  */
15
- static parse(referrer) {
30
+ static parse(referrer, siteHostname = null) {
16
31
  if (!referrer || referrer === '') {
17
32
  return {
18
33
  referrer: null,
@@ -24,16 +39,19 @@ class ReferrerParser {
24
39
  try {
25
40
  const url = new URL(referrer);
26
41
  const domain = url.hostname || null;
27
- const sourceType = this.getSourceType(domain, referrer);
42
+ const sameSite = (host) => String(host || '').toLowerCase().replace(/^www\./, '');
43
+ const sourceType = domain && siteHostname && sameSite(domain) === sameSite(siteHostname)
44
+ ? SOURCE_TYPE.INTERNAL
45
+ : this.getSourceType(domain, referrer);
28
46
 
29
47
  return {
30
- referrer: truncate(referrer, FIELD_MAX_LENGTH.REFERRER),
48
+ referrer: truncate(this.withoutQuery(referrer), FIELD_MAX_LENGTH.REFERRER),
31
49
  referrerDomain: truncate(domain, FIELD_MAX_LENGTH.REFERRER_DOMAIN),
32
50
  sourceType
33
51
  };
34
52
  } catch (error) {
35
53
  return {
36
- referrer: truncate(referrer, FIELD_MAX_LENGTH.REFERRER),
54
+ referrer: truncate(this.withoutQuery(referrer), FIELD_MAX_LENGTH.REFERRER),
37
55
  referrerDomain: null,
38
56
  sourceType: SOURCE_TYPE.UNKNOWN
39
57
  };
@@ -2,7 +2,7 @@
2
2
  * Persisted server secret for visitor hashing.
3
3
  *
4
4
  * agent-instructions SECURITY.md §1: never fall back to a weak, guessable
5
- * default for a security-relevant value — generate one cryptographically and
5
+ * default for a security-relevant value: generate one cryptographically and
6
6
  * persist it. This module is that generator.
7
7
  */
8
8
 
@@ -1,4 +1,16 @@
1
1
  const UAParser = require('ua-parser-js');
2
+ const { isbot, isbotMatch } = require('isbot');
3
+
4
+ /**
5
+ * ua-parser-js 1.x is used because 2.x is licensed AGPL-3.0, which an MIT
6
+ * package cannot pass on to the people who install it. 1.x names a few things
7
+ * differently from the 2.x that recorded the data before 3.2, so its output is
8
+ * mapped to the 2.x names: otherwise one browser would split into two rows of
9
+ * every breakdown, depending on when a view was recorded.
10
+ */
11
+ const OS_NAMES = { 'Mac OS': 'macOS', 'Chromium OS': 'Chrome OS' };
12
+ /** 2.x prefixes these with "Mobile " on phones (not on tablets). */
13
+ const MOBILE_PREFIXED_BROWSERS = new Set(['Chrome', 'Firefox']);
2
14
 
3
15
  /**
4
16
  * Parse user agent string to extract browser, OS, and device information
@@ -23,15 +35,52 @@ class UserAgentParser {
23
35
  const parser = new UAParser(userAgent);
24
36
  const result = parser.getResult();
25
37
 
38
+ // 2.x also recognises a bare "iPad" token that 1.x needs more of the string for.
39
+ const deviceType = result.device.type || (/\biPad\b/.test(userAgent) ? 'tablet' : undefined);
40
+ const onPhone = deviceType === 'mobile';
41
+ const browser = result.browser.name && onPhone && MOBILE_PREFIXED_BROWSERS.has(result.browser.name)
42
+ ? `Mobile ${result.browser.name}`
43
+ : result.browser.name;
44
+
26
45
  return {
27
- browser: result.browser.name || null,
46
+ browser: browser || null,
28
47
  browserVersion: result.browser.version || null,
29
- os: result.os.name || null,
48
+ os: OS_NAMES[result.os.name] || result.os.name || null,
30
49
  osVersion: result.os.version || null,
31
- deviceType: this.getDeviceType(result.device.type)
50
+ deviceType: this.getDeviceType(deviceType)
32
51
  };
33
52
  }
34
53
 
54
+ /**
55
+ * Whether the user agent is a crawler, link previewer, headless browser, or
56
+ * command-line client rather than a person. Such requests are counted in
57
+ * the tracking log but never stored as views.
58
+ * @param {string} userAgent
59
+ * @returns {boolean}
60
+ */
61
+ static isBot(userAgent) {
62
+ return Boolean(userAgent) && isbot(userAgent);
63
+ }
64
+
65
+ /**
66
+ * A short name for the bot, for the tracking log: the part of the user
67
+ * agent that identified it, without its version ("Googlebot", "curl").
68
+ * @param {string} userAgent
69
+ * @returns {string}
70
+ */
71
+ static botName(userAgent) {
72
+ if (!userAgent) return '';
73
+ // Where bots conventionally name themselves: "compatible; Googlebot/2.1",
74
+ // Chrome's headless build, or a leading product token ("curl/8.4.0").
75
+ const compatible = /compatible;\s*([A-Za-z][\w.-]*)/i.exec(userAgent);
76
+ if (compatible) return compatible[1].slice(0, 64);
77
+ if (/HeadlessChrome/.test(userAgent)) return 'HeadlessChrome';
78
+ const product = /^([A-Za-z][\w.-]*)/.exec(userAgent);
79
+ if (product && product[1] !== 'Mozilla') return product[1].slice(0, 64);
80
+ const match = isbotMatch(userAgent);
81
+ return match ? match.trim().split(/[\s/;(]/)[0].slice(0, 64) : '';
82
+ }
83
+
35
84
  /**
36
85
  * Normalize device type
37
86
  * @param {string} type - Device type from ua-parser-js
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Coarse, identifier-free context about a view, read from what the request
3
+ * already carries. Each value is reduced to the least that answers an
4
+ * analytics question, so none of them narrows a visitor down on its own:
5
+ *
6
+ * - language: the primary subtag of the first Accept-Language entry ("en",
7
+ * never "en-GB;q=0.9,de;q=0.8", whose full list is a fingerprinting signal);
8
+ * - hostname: which of the app's registered sites was visited;
9
+ * - UTM tags: the five campaign parameters, and only those. The rest of a
10
+ * landing URL's query string can carry emails, tokens, or IDs, so the
11
+ * tracker never sends it and the server never reads it.
12
+ */
13
+
14
+ const { FIELD_MAX_LENGTH } = require('../constants');
15
+
16
+ /** The query parameters a campaign link carries, in the order they are shown. */
17
+ const UTM_PARAMETERS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
18
+
19
+ const LANGUAGE_SUBTAG = /^[a-z]{2,8}$/;
20
+
21
+ /**
22
+ * @param {string|undefined} acceptLanguage the Accept-Language header
23
+ * @returns {string|null} e.g. "en"
24
+ */
25
+ function primaryLanguage(acceptLanguage) {
26
+ if (typeof acceptLanguage !== 'string') return null;
27
+ const first = acceptLanguage.split(',')[0].split(';')[0].trim();
28
+ const subtag = first.split('-')[0].toLowerCase();
29
+ return LANGUAGE_SUBTAG.test(subtag) && subtag.length <= FIELD_MAX_LENGTH.LANGUAGE ? subtag : null;
30
+ }
31
+
32
+ /**
33
+ * @param {string|null|undefined} origin e.g. "https://www.example.com"
34
+ * @returns {string|null} e.g. "www.example.com"
35
+ */
36
+ function hostnameOf(origin) {
37
+ if (typeof origin !== 'string' || origin === '') return null;
38
+ try {
39
+ const hostname = new URL(origin).hostname.toLowerCase().replace(/\.$/, '');
40
+ return hostname && hostname.length <= FIELD_MAX_LENGTH.HOSTNAME ? hostname : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The UTM tags of a request, trimmed; absent or blank ones are null.
48
+ * Validation has already bounded each to FIELD_MAX_LENGTH.UTM.
49
+ *
50
+ * @param {Record<string, unknown>} params the query string or body
51
+ * @returns {{ utmSource: string|null, utmMedium: string|null, utmCampaign: string|null,
52
+ * utmTerm: string|null, utmContent: string|null }}
53
+ */
54
+ function utmTags(params = {}) {
55
+ const read = (name) => {
56
+ const value = params[name];
57
+ if (typeof value !== 'string') return null;
58
+ const trimmed = value.trim();
59
+ return trimmed === '' ? null : trimmed.slice(0, FIELD_MAX_LENGTH.UTM);
60
+ };
61
+ return {
62
+ utmSource: read('utm_source'),
63
+ utmMedium: read('utm_medium'),
64
+ utmCampaign: read('utm_campaign'),
65
+ utmTerm: read('utm_term'),
66
+ utmContent: read('utm_content'),
67
+ };
68
+ }
69
+
70
+ module.exports = { UTM_PARAMETERS, primaryLanguage, hostnameOf, utmTags };
@@ -1,192 +0,0 @@
1
- /**
2
- * The insights panel above the views table: headline numbers, views over
3
- * time, a world map, and breakdowns, for exactly the rows the table's filters
4
- * select (one app or all, date range, search, modified filter).
5
- */
6
-
7
- import { api } from './api.js';
8
- import { barList, statTiles, trendChart, trendTable, worldMap } from './charts.js';
9
- import { el, replaceChildren, uniqueId } from './dom.js';
10
- import { formatDateTime } from './format.js';
11
- import { t, tOr } from './i18n.js';
12
- import { ALL_APPS, STORAGE_KEY } from './constants.js';
13
-
14
- const DAY_MS = 24 * 60 * 60 * 1000;
15
-
16
- /** Breakdown cards, in display order; `allOnly` cards appear for every app at once. */
17
- const BREAKDOWNS = [
18
- { dim: 'source', label: (v) => (v === null ? t('insights.unknown') : tOr(`sources.${v}`, v)) },
19
- { dim: 'deviceSize', label: (v) => (v === null ? t('insights.unknown') : tOr(`deviceSizes.${v}`, v)) },
20
- { dim: 'browser', label: (v) => v ?? t('insights.unknown') },
21
- { dim: 'os', label: (v) => v ?? t('insights.unknown') },
22
- { dim: 'eventType', label: (v) => (v === null ? t('insights.unknown') : tOr(`eventTypes.${v}`, v)) },
23
- { dim: 'app', label: (v) => v ?? t('insights.unknown'), allOnly: true },
24
- ];
25
-
26
- /** The date after `period` by one bucket, as YYYY-MM-DD. */
27
- function nextPeriod(period, bucket) {
28
- const date = new Date(`${period}T00:00:00Z`);
29
- if (bucket === 'month') date.setUTCMonth(date.getUTCMonth() + 1);
30
- else date.setTime(date.getTime() + (bucket === 'week' ? 7 : 1) * DAY_MS);
31
- return date.toISOString().slice(0, 10);
32
- }
33
-
34
- /**
35
- * Insert zero-count buckets between the first and last period, so a quiet
36
- * day reads as a dip rather than disappearing from the line.
37
- */
38
- export function fillGaps(trend, bucket) {
39
- if (trend.length < 2) return trend;
40
- const byPeriod = new Map(trend.map((point) => [point.period, point]));
41
- const filled = [];
42
- const last = trend[trend.length - 1].period;
43
- for (let period = trend[0].period; period <= last; period = nextPeriod(period, bucket)) {
44
- filled.push(byPeriod.get(period) || { period, views: 0, uniqueViews: 0 });
45
- }
46
- return filled;
47
- }
48
-
49
- function readOpen() {
50
- try {
51
- return localStorage.getItem(STORAGE_KEY.INSIGHTS_OPEN) !== 'false';
52
- } catch {
53
- return true;
54
- }
55
- }
56
-
57
- function storeOpen(open) {
58
- try {
59
- localStorage.setItem(STORAGE_KEY.INSIGHTS_OPEN, String(open));
60
- } catch {
61
- // Storage blocked: the panel simply opens by default next time.
62
- }
63
- }
64
-
65
- /** A titled card; `control` sits in the header, beside the title. */
66
- function card(titleKey, body, control, className = '') {
67
- const titleId = uniqueId('chart-title');
68
- return el('section', { className: `chart-card ${className}`.trim(), attrs: { 'aria-labelledby': titleId } }, [
69
- el('header', { className: 'chart-card-header' }, [
70
- el('h3', { className: 'chart-title', text: t(titleKey), attrs: { id: titleId } }),
71
- control,
72
- ]),
73
- body,
74
- ]);
75
- }
76
-
77
- /**
78
- * @param {{ reportError: (error: unknown) => void, onData?: (data: object) => void }} deps
79
- * onData receives each analysis, so the filter row can offer the event types seen
80
- */
81
- export function createInsightsPanel({ reportError, onData = () => {} }) {
82
- let open = readOpen();
83
- let seq = 0;
84
- let data = null;
85
- let showTrendTable = false;
86
- let allApps = false;
87
-
88
- const bodyId = uniqueId('insights-body');
89
- const toggle = el('button', {
90
- className: 'insights-toggle',
91
- attrs: { type: 'button', 'aria-expanded': String(open), 'aria-controls': bodyId },
92
- }, [
93
- el('span', { className: 'prompt-char', text: '❯', attrs: { 'aria-hidden': 'true' } }),
94
- el('span', { text: t('insights.title') }),
95
- el('span', { className: 'insights-caret', text: '▾', attrs: { 'aria-hidden': 'true' } }),
96
- ]);
97
- const caption = el('span', { className: 'insights-caption' });
98
- const body = el('div', { className: 'insights-body', attrs: { id: bodyId } });
99
- body.hidden = !open;
100
-
101
- const element = el('section', { className: 'insights', attrs: { 'aria-label': t('insights.title') } }, [
102
- el('div', { className: 'insights-header' }, [toggle, caption]),
103
- body,
104
- ]);
105
-
106
- toggle.addEventListener('click', () => {
107
- open = !open;
108
- storeOpen(open);
109
- toggle.setAttribute('aria-expanded', String(open));
110
- body.hidden = !open;
111
- if (open) render();
112
- });
113
-
114
- function trendCard() {
115
- const points = fillGaps(data.trend, data.bucket);
116
- const tableToggle = el('button', {
117
- className: 'btn btn-ghost btn-small',
118
- text: showTrendTable ? t('insights.showChart') : t('insights.showTable'),
119
- attrs: { type: 'button', 'aria-pressed': String(showTrendTable) },
120
- on: { click: () => {
121
- showTrendTable = !showTrendTable;
122
- render();
123
- } },
124
- });
125
- // The last bucket is still filling up if it has not ended yet, so its
126
- // dip is not a real decline; the tooltip says "so far".
127
- const lastPeriod = points[points.length - 1]?.period;
128
- const lastInProgress = Boolean(lastPeriod) && nextPeriod(lastPeriod, data.bucket) > new Date().toISOString().slice(0, 10);
129
- const content = showTrendTable
130
- ? el('div', { className: 'chart-body chart-table-wrap' }, [trendTable({ points, bucket: data.bucket })])
131
- : trendChart({ points, bucket: data.bucket, lastInProgress });
132
- return card('insights.trendTitle', content, tableToggle, 'chart-trend');
133
- }
134
-
135
- function mapCard() {
136
- // Every type, or the one chosen in the filter row above: the rows are
137
- // already filtered by the server, so the map just shows them.
138
- return card('insights.mapTitle', worldMap({ countries: data.countries, type: null }), null, 'chart-map');
139
- }
140
-
141
- function render() {
142
- if (!open || !data) return;
143
- const { totals } = data;
144
- const period = totals.firstAt
145
- ? t('insights.span', { from: formatDateTime(totals.firstAt), to: formatDateTime(totals.lastAt) })
146
- : t('insights.noData');
147
-
148
- replaceChildren(body, [
149
- statTiles([
150
- { label: t('insights.views'), value: totals.views },
151
- { label: t('insights.visitors'), value: totals.visitors },
152
- { label: t('insights.uniqueShare'), value: totals.views ? totals.uniqueViews / totals.views : 0, format: 'percent' },
153
- { label: t('insights.countries'), value: totals.countries },
154
- { label: t('insights.modified'), value: totals.modified },
155
- ]),
156
- el('p', { className: 'insights-span', text: period }),
157
- el('div', { className: 'insights-grid' }, [trendCard(), mapCard()]),
158
- el('div', { className: 'breakdown-grid' }, BREAKDOWNS
159
- .filter((breakdown) => !breakdown.allOnly || allApps)
160
- .map((breakdown) => card(`insights.breakdown.${breakdown.dim}`, barList({
161
- entries: data.breakdowns[breakdown.dim],
162
- total: totals.views,
163
- labelFor: breakdown.label,
164
- })))),
165
- ]);
166
- }
167
-
168
- /**
169
- * Fetch and draw the analysis for a filter set. The previous render stays
170
- * up, dimmed, until the new one arrives: no flash, no layout jump.
171
- * @param {{ appId: string, status: string, modified: string, search: string, range: string }} query
172
- */
173
- async function load({ appId, ...filters }) {
174
- const mine = ++seq;
175
- allApps = appId === ALL_APPS;
176
- caption.textContent = allApps ? t('insights.scopeAll') : t('insights.scopeApp', { app: appId });
177
- element.classList.add('loading');
178
- try {
179
- const result = allApps ? await api.analyticsAll(filters) : await api.analytics(appId, filters);
180
- if (mine !== seq) return;
181
- data = result;
182
- onData(result);
183
- render();
184
- } catch (error) {
185
- if (mine === seq) reportError(error);
186
- } finally {
187
- if (mine === seq) element.classList.remove('loading');
188
- }
189
- }
190
-
191
- return { element, load };
192
- }