@harshankur/viewcounter 3.0.1 → 3.1.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 (44) hide show
  1. package/.env.example +20 -0
  2. package/README.md +174 -4
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +1875 -0
  6. package/admin/favicon.ico +0 -0
  7. package/admin/favicon.svg +9 -0
  8. package/admin/icon-192.png +0 -0
  9. package/admin/icon-512.png +0 -0
  10. package/admin/index.html +89 -0
  11. package/admin/js/api.js +100 -0
  12. package/admin/js/charts.js +502 -0
  13. package/admin/js/clamp.js +41 -0
  14. package/admin/js/constants.js +150 -0
  15. package/admin/js/dom.js +83 -0
  16. package/admin/js/format.js +79 -0
  17. package/admin/js/i18n.js +80 -0
  18. package/admin/js/insights.js +192 -0
  19. package/admin/js/listbox.js +144 -0
  20. package/admin/js/logs.js +167 -0
  21. package/admin/js/main.js +235 -0
  22. package/admin/js/modal.js +171 -0
  23. package/admin/js/table.js +134 -0
  24. package/admin/js/theme.js +72 -0
  25. package/admin/js/toast.js +47 -0
  26. package/admin/js/viewDialogs.js +208 -0
  27. package/admin/js/views.js +685 -0
  28. package/admin/locales/en.json +394 -0
  29. package/admin/site.webmanifest +20 -0
  30. package/config/index.js +83 -0
  31. package/constants.js +215 -2
  32. package/db/AdminRepository.js +562 -0
  33. package/db/DatabaseManager.js +94 -20
  34. package/db/LogRepository.js +217 -0
  35. package/db/adminSchema.js +244 -0
  36. package/db/retention.js +97 -0
  37. package/index.js +39 -6
  38. package/middleware/adminAuth.js +204 -0
  39. package/middleware/adminValidation.js +253 -0
  40. package/package.json +16 -9
  41. package/routes/admin.js +438 -0
  42. package/routes/analytics.js +11 -2
  43. package/utils/cookieUtils.js +47 -0
  44. package/utils/errorUtils.js +35 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Front-end constants for the admin UI (CODE_STANDARDS.md §0/§1).
3
+ *
4
+ * Limits that the server enforces (batch size, field lengths, page sizes) are
5
+ * NOT duplicated here: the UI reads them from GET api/meta, so the two can
6
+ * never disagree. What lives here is either UI-only or part of the wire
7
+ * contract; the wire-contract values are checked against constants.js by
8
+ * tests/adminUiContract.test.js.
9
+ */
10
+
11
+ /** Human-facing product name. Mirrors APP_NAME in the server constants. */
12
+ export const APP_NAME = 'ViewCounter';
13
+
14
+ /** Storage-key namespace. Mirrors APP_SLUG in the server constants. */
15
+ export const APP_SLUG = 'viewcounter';
16
+
17
+ /** Relative to the page, so the UI works wherever the router is mounted. */
18
+ export const API_BASE = 'api';
19
+
20
+ /** Must match ADMIN.CSRF_HEADER on the server. */
21
+ export const CSRF_HEADER = 'x-csrf-token';
22
+
23
+ /** Mirrors ADMIN_ERROR_CODE on the server. */
24
+ export const ERROR_CODE = Object.freeze({
25
+ UNAUTHENTICATED: 'UNAUTHENTICATED',
26
+ INVALID_PASSWORD: 'INVALID_PASSWORD',
27
+ TOO_MANY_ATTEMPTS: 'TOO_MANY_ATTEMPTS',
28
+ RATE_LIMITED: 'RATE_LIMITED',
29
+ CSRF_REJECTED: 'CSRF_REJECTED',
30
+ VALIDATION_FAILED: 'VALIDATION_FAILED',
31
+ NOT_FOUND: 'NOT_FOUND',
32
+ SERVER_ERROR: 'SERVER_ERROR',
33
+ /** Client-side only: the request never got an answer. */
34
+ NETWORK: 'NETWORK',
35
+ });
36
+
37
+ /** Mirrors VIEW_STATUS on the server. */
38
+ export const VIEW_STATUS = Object.freeze({
39
+ ACTIVE: 'active',
40
+ DELETED: 'deleted',
41
+ ALL: 'all',
42
+ });
43
+
44
+ /** Mirrors MODIFIED_FILTER on the server. */
45
+ export const MODIFIED_FILTER = Object.freeze({
46
+ ANY: 'any',
47
+ MODIFIED: 'modified',
48
+ UNMODIFIED: 'unmodified',
49
+ });
50
+
51
+ export const SORT_ORDER = Object.freeze({
52
+ ASC: 'asc',
53
+ DESC: 'desc',
54
+ });
55
+
56
+ /** The sections of the UI. */
57
+ export const TAB = Object.freeze({
58
+ VIEWS: 'views',
59
+ TRASH: 'trash',
60
+ ADMIN_LOG: 'adminLog',
61
+ VIEW_LOG: 'viewLog',
62
+ });
63
+
64
+ export const THEME = Object.freeze({
65
+ LIGHT: 'light',
66
+ DARK: 'dark',
67
+ AUTO: 'auto',
68
+ });
69
+
70
+ export const STORAGE_KEY = Object.freeze({
71
+ THEME: `${APP_SLUG}-admin-theme`,
72
+ LAST_APP: `${APP_SLUG}-admin-last-app`,
73
+ INSIGHTS_OPEN: `${APP_SLUG}-admin-insights-open`,
74
+ });
75
+
76
+ export const TOAST_DURATION_MS = 4000;
77
+
78
+ /** Wait after the last keystroke before searching. */
79
+ export const SEARCH_DEBOUNCE_MS = 300;
80
+
81
+ export const DEFAULT_LOCALE = 'en';
82
+
83
+ /** Every field a view row exposes, in display order for the details dialog. */
84
+ export const VIEW_DETAIL_FIELDS = Object.freeze([
85
+ 'id', 'timestamp', 'pagePath', 'pageTitle', 'referrer', 'referrerDomain', 'sourceType',
86
+ 'deviceSize', 'deviceType', 'country', 'maskedIp', 'browser', 'browserVersion', 'os',
87
+ 'osVersion', 'sessionId', 'eventType', 'eventData', 'isUnique', 'note', 'adminModifiedAt',
88
+ 'deletedAt',
89
+ ]);
90
+
91
+ /** Editable fields rendered as free text, and those rendered otherwise. */
92
+ export const TEXT_FIELDS = Object.freeze(['pagePath', 'pageTitle', 'referrer', 'eventType']);
93
+ export const DEVICE_SIZE_FIELD = 'deviceSize';
94
+ export const EVENT_DATA_FIELD = 'eventData';
95
+
96
+ /** Rows of the event-data editor. */
97
+ export const EVENT_DATA_ROWS = 6;
98
+ export const NOTE_ROWS = 4;
99
+
100
+ /** Two-line clamp for page titles, one line for everything else. */
101
+ export const CLAMP_LINES = Object.freeze({ SINGLE: 1, DOUBLE: 2 });
102
+
103
+ /** Key names the keyboard handlers compare against. */
104
+ export const KEY = Object.freeze({
105
+ ESCAPE: 'Escape',
106
+ ENTER: 'Enter',
107
+ SPACE: ' ',
108
+ TAB: 'Tab',
109
+ ARROW_UP: 'ArrowUp',
110
+ ARROW_DOWN: 'ArrowDown',
111
+ ARROW_LEFT: 'ArrowLeft',
112
+ ARROW_RIGHT: 'ArrowRight',
113
+ HOME: 'Home',
114
+ END: 'End',
115
+ });
116
+
117
+ /** Sentinel app selection meaning every app. Not a valid app ID ('*' is refused). */
118
+ export const ALL_APPS = '*';
119
+
120
+ /** Mirrors ADMIN_RANGE on the server. */
121
+ export const RANGE = Object.freeze({
122
+ WEEK: '7d',
123
+ MONTH: '30d',
124
+ QUARTER: '90d',
125
+ YEAR: '1y',
126
+ ALL: 'all',
127
+ });
128
+
129
+ /** Geometry and limits for the insights charts. */
130
+ export const CHART = Object.freeze({
131
+ TREND: Object.freeze({
132
+ WIDTH: 720,
133
+ HEIGHT: 220,
134
+ MARGIN: Object.freeze({ top: 12, right: 12, bottom: 28, left: 44 }),
135
+ }),
136
+ Y_TICKS: 4,
137
+ TICK_GAP: 8,
138
+ X_LABEL_GAP: 18,
139
+ /** r >= 4, so a marker is at least 8px across. */
140
+ DOT_RADIUS: 4,
141
+ TOOLTIP_OFFSET: 12,
142
+ /** Event types listed in a country's tooltip. */
143
+ TOOLTIP_TYPES: 4,
144
+ /** Sequential classes on the map; must match --map-1..5 in admin.css. */
145
+ MAP_CLASSES: 5,
146
+ COUNTRY_LIST_LIMIT: 10,
147
+ });
148
+
149
+ /** JSON indentation in the details and edit dialogs. */
150
+ export const JSON_INDENT = 2;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * DOM construction helpers.
3
+ *
4
+ * Every value that came from the database is untrusted: page titles, paths,
5
+ * referrers, and event data are sent by anonymous visitors. This module is the
6
+ * only way the UI builds elements, and it only ever sets text through
7
+ * `textContent` and attributes through `setAttribute`. There is no path here,
8
+ * or anywhere in the UI, that parses a string as HTML; ESLint forbids
9
+ * innerHTML and its relatives under admin/.
10
+ */
11
+
12
+ /**
13
+ * Create an element.
14
+ * @param {string} tag
15
+ * @param {{ className?: string, text?: string, attrs?: Record<string, string|number|boolean|null|undefined>,
16
+ * on?: Record<string, EventListener>, dataset?: Record<string, string> }} [options]
17
+ * @param {Array<Node|string|null|undefined|false>} [children]
18
+ * @returns {HTMLElement}
19
+ */
20
+ export function el(tag, { className, text, attrs = {}, on = {}, dataset = {} } = {}, children = []) {
21
+ const node = document.createElement(tag);
22
+ if (className) node.className = className;
23
+ if (text !== undefined && text !== null) node.textContent = String(text);
24
+ for (const [name, value] of Object.entries(attrs)) {
25
+ if (value === false || value === null || value === undefined) continue;
26
+ node.setAttribute(name, value === true ? '' : String(value));
27
+ }
28
+ for (const [name, value] of Object.entries(dataset)) node.dataset[name] = value;
29
+ for (const [event, handler] of Object.entries(on)) node.addEventListener(event, handler);
30
+ append(node, children);
31
+ return node;
32
+ }
33
+
34
+ /** Append children, turning strings into text nodes and skipping blanks. */
35
+ export function append(parent, children) {
36
+ for (const child of children) {
37
+ if (child === null || child === undefined || child === false) continue;
38
+ parent.append(typeof child === 'string' ? document.createTextNode(child) : child);
39
+ }
40
+ return parent;
41
+ }
42
+
43
+ /** Remove every child. */
44
+ export function clear(node) {
45
+ while (node.firstChild) node.firstChild.remove();
46
+ return node;
47
+ }
48
+
49
+ /** Replace a node's children in one step. */
50
+ export function replaceChildren(node, children) {
51
+ clear(node);
52
+ return append(node, children);
53
+ }
54
+
55
+ /** @returns {HTMLElement} */
56
+ export function byId(id) {
57
+ return document.getElementById(id);
58
+ }
59
+
60
+ /** Every focusable element inside `root`, in tab order. */
61
+ export function focusableWithin(root) {
62
+ const selector = [
63
+ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'textarea:not([disabled])',
64
+ '[tabindex]:not([tabindex="-1"])',
65
+ ].join(',');
66
+ return [...root.querySelectorAll(selector)].filter((node) => !node.hidden && node.offsetParent !== null);
67
+ }
68
+
69
+ /** Debounce a function by `ms`. */
70
+ export function debounce(fn, ms) {
71
+ let timer = null;
72
+ return (...args) => {
73
+ clearTimeout(timer);
74
+ timer = setTimeout(() => fn(...args), ms);
75
+ };
76
+ }
77
+
78
+ let idCounter = 0;
79
+ /** A document-unique id for ARIA relationships. */
80
+ export function uniqueId(prefix) {
81
+ idCounter += 1;
82
+ return `${prefix}-${idCounter}`;
83
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Locale-aware formatting (FRONTEND.md §8: dates and numbers are localized
3
+ * too, not only strings).
4
+ */
5
+
6
+ import { currentLocale, t } from './i18n.js';
7
+
8
+ /** Intl time styles: with seconds, and without. */
9
+ const TIME_STYLE = Object.freeze({ FULL: 'medium', SHORT: 'short' });
10
+
11
+ function formatParts(value, options) {
12
+ if (value === null || value === undefined || value === '') return t('common.none');
13
+ const date = value instanceof Date ? value : new Date(value);
14
+ if (Number.isNaN(date.getTime())) return String(value);
15
+ return new Intl.DateTimeFormat(currentLocale(), options).format(date);
16
+ }
17
+
18
+ function formatWith(value, timeStyle) {
19
+ return formatParts(value, { dateStyle: 'medium', timeStyle });
20
+ }
21
+
22
+ /** Date and time to the second: logs and the details dialog. */
23
+ export function formatDateTime(value) {
24
+ return formatWith(value, TIME_STYLE.FULL);
25
+ }
26
+
27
+ /** The date alone: the first line of a table's time cell. */
28
+ export function formatDate(value) {
29
+ return formatParts(value, { dateStyle: 'medium' });
30
+ }
31
+
32
+ /**
33
+ * The time of day alone: the second line of a table's time cell. Logs keep the
34
+ * seconds, since they order events that can be moments apart.
35
+ */
36
+ export function formatTime(value, { seconds = false } = {}) {
37
+ return formatParts(value, { timeStyle: seconds ? TIME_STYLE.FULL : TIME_STYLE.SHORT });
38
+ }
39
+
40
+ /** @param {number} value */
41
+ export function formatNumber(value) {
42
+ return new Intl.NumberFormat(currentLocale()).format(Number(value) || 0);
43
+ }
44
+
45
+ /** 1,284 stays exact; 12,900 becomes 12.9K. */
46
+ export function formatCompact(value) {
47
+ return new Intl.NumberFormat(currentLocale(), { notation: 'compact', maximumFractionDigits: 1 }).format(Number(value) || 0);
48
+ }
49
+
50
+ /** A ratio (0..1) as a percentage. */
51
+ export function formatPercent(ratio) {
52
+ return new Intl.NumberFormat(currentLocale(), { style: 'percent', maximumFractionDigits: 1 }).format(Number(ratio) || 0);
53
+ }
54
+
55
+ /**
56
+ * A trend bucket label. Buckets are named by the date they start on
57
+ * (YYYY-MM-DD, from the server) and read as calendar dates, not instants, so
58
+ * they are formatted in UTC to avoid shifting a day across time zones.
59
+ * @param {string} period
60
+ * @param {'day'|'week'|'month'} bucket
61
+ * @param {{ long?: boolean }} [options] long adds the year, and "week of"
62
+ */
63
+ export function formatPeriod(period, bucket, { long = false } = {}) {
64
+ const date = new Date(`${period}T00:00:00Z`);
65
+ if (Number.isNaN(date.getTime())) return period;
66
+ const locale = currentLocale();
67
+ if (bucket === 'month') {
68
+ return new Intl.DateTimeFormat(locale, { month: long ? 'long' : 'short', year: 'numeric', timeZone: 'UTC' }).format(date);
69
+ }
70
+ const day = new Intl.DateTimeFormat(locale, {
71
+ day: 'numeric', month: 'short', ...(long ? { year: 'numeric' } : {}), timeZone: 'UTC',
72
+ }).format(date);
73
+ return bucket === 'week' && long ? t('insights.weekOf', { date: day }) : day;
74
+ }
75
+
76
+ /** A value for display, with a translated placeholder for empty. */
77
+ export function orNone(value) {
78
+ return value === null || value === undefined || value === '' ? t('common.none') : String(value);
79
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Translation layer (FRONTEND.md §8).
3
+ *
4
+ * Every user-visible string is a key resolved here. One locale ships today;
5
+ * adding another is a new file in locales/ with the same key set, which
6
+ * tests/adminUiContract.test.js enforces.
7
+ *
8
+ * Keys are dotted paths into the locale JSON. `{name}` placeholders are
9
+ * interpolated. When params include `count` and the key names an object with
10
+ * plural categories (`one`, `other`, ...), the category is chosen with
11
+ * Intl.PluralRules for the active locale.
12
+ */
13
+
14
+ import { DEFAULT_LOCALE } from './constants.js';
15
+
16
+ let messages = {};
17
+ let locale = DEFAULT_LOCALE;
18
+ let pluralRules = new Intl.PluralRules(DEFAULT_LOCALE);
19
+
20
+ /** Load a locale bundle. Called once at startup. */
21
+ export async function loadLocale(code = DEFAULT_LOCALE) {
22
+ const response = await fetch(`locales/${code}.json`, { credentials: 'same-origin' });
23
+ messages = await response.json();
24
+ locale = code;
25
+ pluralRules = new Intl.PluralRules(code);
26
+ document.documentElement.lang = code;
27
+ }
28
+
29
+ export function currentLocale() {
30
+ return locale;
31
+ }
32
+
33
+ function lookup(key) {
34
+ return key.split('.').reduce((node, part) => (node && typeof node === 'object' ? node[part] : undefined), messages);
35
+ }
36
+
37
+ /**
38
+ * Resolve a key.
39
+ * @param {string} key
40
+ * @param {Record<string, string|number>} [params]
41
+ * @returns {string} the translation, or the key itself if missing, so a gap
42
+ * is visible on screen rather than silently blank
43
+ */
44
+ export function t(key, params = {}) {
45
+ let entry = lookup(key);
46
+ if (entry && typeof entry === 'object' && typeof params.count === 'number') {
47
+ entry = entry[pluralRules.select(params.count)] ?? entry.other;
48
+ }
49
+ if (typeof entry !== 'string') return key;
50
+ return entry.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match));
51
+ }
52
+
53
+ /**
54
+ * Resolve a key that may legitimately be absent, such as a device size or
55
+ * source type defined in the server's config rather than in this bundle, and
56
+ * fall back to the raw value instead of showing the key.
57
+ * @param {string} key
58
+ * @param {string} fallback
59
+ */
60
+ export function tOr(key, fallback, params = {}) {
61
+ const result = t(key, params);
62
+ return result === key ? fallback : result;
63
+ }
64
+
65
+ /**
66
+ * Translate static markup: `data-i18n` sets text, `data-i18n-attr` sets
67
+ * attributes as `attr:key;attr:key`.
68
+ * @param {ParentNode} root
69
+ */
70
+ export function applyTranslations(root = document) {
71
+ for (const node of root.querySelectorAll('[data-i18n]')) {
72
+ node.textContent = t(node.dataset.i18n);
73
+ }
74
+ for (const node of root.querySelectorAll('[data-i18n-attr]')) {
75
+ for (const pair of node.dataset.i18nAttr.split(';')) {
76
+ const [attr, key] = pair.split(':').map((part) => part.trim());
77
+ if (attr && key) node.setAttribute(attr, t(key));
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,192 @@
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
+ }