@harshankur/viewcounter 3.1.0 → 3.2.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 +34 -10
  2. package/README.md +306 -136
  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 +55 -7
  25. package/db/LogRepository.js +172 -35
  26. package/db/adminSchema.js +89 -4
  27. package/db/adminSessionStore.js +104 -0
  28. package/db/analysis.js +479 -0
  29. package/db/rejectionCounter.js +117 -0
  30. package/index.js +53 -19
  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 +197 -18
  39. package/tracker/tracker.js +191 -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
package/admin/js/views.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The views table, used twice: the Views tab (live rows, optionally with the
3
- * trashed ones shown inline) and the Trash tab (trashed rows only).
3
+ * trashed ones shown inline) and the Trash tab (trashed rows only). These
4
+ * rows are the data every statistic on the Overview is computed from.
4
5
  *
5
6
  * Selection is a set of view IDs that survives paging, so a batch can span
6
7
  * pages, and is cleared whenever the query changes shape (app, filter,
@@ -8,20 +9,22 @@
8
9
  */
9
10
 
10
11
  import { api } from './api.js';
12
+ import { appEntries, createAppTabs } from './appTabs.js';
13
+ import { regionName } from './charts.js';
11
14
  import { clampText } from './clamp.js';
15
+ import { createDataTable } from './dataTable.js';
12
16
  import { el, replaceChildren, debounce, uniqueId } from './dom.js';
13
- import { formatDateTime, formatNumber, orNone } from './format.js';
17
+ import { formatDate, formatDateTime, formatDuration, formatNumber, formatTime, orNone } from './format.js';
18
+ import { icon } from './icons.js';
14
19
  import { t, tOr } from './i18n.js';
15
20
  import { createListbox } from './listbox.js';
16
21
  import { confirmModal } from './modal.js';
17
- import { createPager, createSegmented, headerCell, messageRow, timeCell } from './table.js';
22
+ import { createPager, createSegmented } from './table.js';
18
23
  import { showToast, TOAST_TYPE } from './toast.js';
19
24
  import { openDetails, openEditor, openNoteEditor } from './viewDialogs.js';
20
- import { createInsightsPanel } from './insights.js';
25
+ import { valueLabel } from './overview.js';
21
26
  import {
22
27
  ALL_APPS,
23
- CLAMP_LINES,
24
- KEY,
25
28
  RANGE,
26
29
  MODIFIED_FILTER,
27
30
  SEARCH_DEBOUNCE_MS,
@@ -35,25 +38,93 @@ export const PANEL_MODE = Object.freeze({
35
38
  TRASH: 'trash',
36
39
  });
37
40
 
38
- /** Columns in display order; `sort` names the API sort key. */
39
- const COLUMNS = [
40
- { key: 'timestamp', sort: 'timestamp', className: 'col-time' },
41
- { key: 'app', className: 'col-app' },
42
- { key: 'page', sort: 'page', className: 'col-page' },
43
- { key: 'source', sort: 'source', className: 'col-source' },
44
- { key: 'device', sort: 'deviceSize', className: 'col-device' },
45
- { key: 'country', sort: 'country', className: 'col-country' },
46
- { key: 'client', sort: 'browser', className: 'col-client' },
47
- { key: 'event', sort: 'eventType', className: 'col-event' },
48
- { key: 'status', className: 'col-status' },
49
- { key: 'actions', className: 'col-actions' },
41
+ /** Width of the row actions column: four icon buttons and their padding. */
42
+ const ACTIONS_WIDTH = 148;
43
+
44
+ /** A cell of two lines: what matters, and a detail under it. */
45
+ const two = (primary, secondary) => [
46
+ clampText(primary, { className: 'cell-primary' }),
47
+ clampText(secondary, { className: 'cell-secondary' }),
48
+ ];
49
+ /** A cell of one line. */
50
+ const one = (text, className = 'cell-primary') => [clampText(text, { className })];
51
+ const when = (value) => (value ? two(formatDate(value), formatTime(value)) : one(t('common.none')));
52
+
53
+ /** A custom event's data in a line: its first few simple values. */
54
+ function eventSummary(data) {
55
+ if (!data || typeof data !== 'object') return t('common.none');
56
+ const parts = Object.entries(data)
57
+ .filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value))
58
+ .slice(0, 3)
59
+ .map(([key, value]) => `${key}: ${value}`);
60
+ return parts.length ? parts.join(', ') : t('common.none');
61
+ }
62
+
63
+ /**
64
+ * Every column a views table can show, in default order. The ones marked
65
+ * `hidden` are there to be chosen; `sort` names the API sort key, used when
66
+ * the server offers it.
67
+ */
68
+ const VIEW_COLUMNS = [
69
+ { id: 'time', width: 132, sort: 'timestamp', cell: (view) => when(view.timestamp) },
70
+ { id: 'deleted', width: 136, sort: 'deletedAt', trashOnly: true, cell: (view) => when(view.deletedAt) },
71
+ { id: 'app', width: 136, allAppsOnly: true, cell: (view) => one(view.appId, 'mono') },
72
+ {
73
+ id: 'page', width: 260, minWidth: 200, grow: true, sort: 'page',
74
+ cell: (view) => two(orNone(view.pagePath), view.hostname ? `${view.hostname} · ${orNone(view.pageTitle)}` : orNone(view.pageTitle)),
75
+ },
76
+ {
77
+ id: 'source', width: 144, sort: 'source',
78
+ cell: (view) => two(
79
+ view.sourceType ? tOr(`sources.${view.sourceType}`, view.sourceType) : t('common.none'),
80
+ orNone(view.utmCampaign ?? view.referrerDomain),
81
+ ),
82
+ },
83
+ { id: 'status', width: 150, minWidth: 110 },
84
+ {
85
+ id: 'event', width: 130, sort: 'eventType',
86
+ cell: (view) => two(view.eventType ? tOr(`eventTypes.${view.eventType}`, view.eventType) : t('common.none'), eventSummary(view.eventData)),
87
+ },
88
+ {
89
+ id: 'country', width: 140, sort: 'country',
90
+ cell: (view) => two(view.country ? regionName(view.country) : t('common.none'), orNone([view.city, view.region].filter(Boolean).join(', '))),
91
+ },
92
+ {
93
+ id: 'device', width: 104, sort: 'deviceSize',
94
+ cell: (view) => two(
95
+ view.deviceSize ? tOr(`deviceSizes.${view.deviceSize}`, view.deviceSize) : t('common.none'),
96
+ view.deviceType ? tOr(`deviceTypes.${view.deviceType}`, view.deviceType) : t('common.none'),
97
+ ),
98
+ },
99
+ { id: 'client', width: 124, sort: 'browser', cell: (view) => two(orNone(view.browser), orNone(view.os)) },
100
+ {
101
+ id: 'engagement', width: 130, sort: 'engagedMs',
102
+ cell: (view) => two(
103
+ formatDuration(view.engagedMs),
104
+ view.scrollDepth === null || view.scrollDepth === undefined ? t('common.none') : t('details.scrolled', { percent: view.scrollDepth }),
105
+ ),
106
+ },
107
+ {
108
+ id: 'campaign', width: 180, sort: 'campaign',
109
+ cell: (view) => two(orNone(view.utmCampaign), orNone([view.utmSource, view.utmMedium, view.utmTerm, view.utmContent].filter(Boolean).join(' · '))),
110
+ },
111
+ { id: 'title', width: 220, hidden: true, sort: 'title', cell: (view) => one(orNone(view.pageTitle)) },
112
+ { id: 'site', width: 180, hidden: true, sort: 'hostname', cell: (view) => one(orNone(view.hostname)) },
113
+ { id: 'referrer', width: 240, hidden: true, sort: 'referrer', cell: (view) => two(orNone(view.referrerDomain), orNone(view.referrer)) },
114
+ { id: 'language', width: 150, hidden: true, sort: 'language', cell: (view) => one(valueLabel('language', view.language)) },
115
+ { id: 'browser', width: 140, hidden: true, sort: 'browser', cell: (view) => two(orNone(view.browser), orNone(view.browserVersion)) },
116
+ { id: 'system', width: 140, hidden: true, sort: 'os', cell: (view) => two(orNone(view.os), orNone(view.osVersion)) },
117
+ { id: 'ip', width: 140, hidden: true, cell: (view) => one(orNone(view.maskedIp), 'mono') },
118
+ { id: 'session', width: 160, hidden: true, cell: (view) => one(orNone(view.sessionId), 'mono') },
119
+ { id: 'viewId', width: 130, hidden: true, cell: (view) => one(view.id, 'mono') },
120
+ { id: 'note', width: 220, hidden: true, cell: (view) => one(orNone(view.note)) },
121
+ { id: 'modified', width: 136, hidden: true, sort: 'modifiedAt', cell: (view) => when(view.adminModifiedAt) },
50
122
  ];
51
- /** Plus the selection column. */
52
- const COLUMN_COUNT = COLUMNS.length + 1;
53
123
 
54
124
  /**
55
125
  * @param {{ mode: string, meta: object, context: { apps: () => object[], appId: () => string,
56
- * setAppId: (id: string) => void, refreshApps: () => Promise<void>, reportError: (error: unknown) => void } }} deps
126
+ * setAppId: (id: string) => void, refreshApps: () => Promise<void>, reportError: (error: unknown) => void,
127
+ * viewsChanged: () => void } }} deps
57
128
  */
58
129
  export function createViewsPanel({ mode, meta, context }) {
59
130
  const isTrash = mode === PANEL_MODE.TRASH;
@@ -62,7 +133,9 @@ export function createViewsPanel({ mode, meta, context }) {
62
133
  modified: MODIFIED_FILTER.ANY,
63
134
  range: RANGE.ALL,
64
135
  eventType: '',
65
- knownEventTypes: new Set(),
136
+ knownEventTypes: [],
137
+ /** Breakdown values carried over from the Overview, such as one country. */
138
+ where: {},
66
139
  search: '',
67
140
  sort: isTrash ? 'deletedAt' : 'timestamp',
68
141
  order: SORT_ORDER.DESC,
@@ -80,98 +153,20 @@ export function createViewsPanel({ mode, meta, context }) {
80
153
  return state.showDeleted ? VIEW_STATUS.ALL : VIEW_STATUS.ACTIVE;
81
154
  };
82
155
 
83
- /** The filters the table and the insights above it share. */
156
+ /** The filters of the listing, the same the Overview's analysis takes. */
84
157
  const filters = () => ({
85
158
  status: status(),
86
159
  modified: state.modified,
87
160
  range: state.range,
88
161
  eventType: state.eventType,
162
+ where: state.where,
89
163
  search: state.search,
90
164
  });
91
165
 
92
166
  // ---- Toolbar -----------------------------------------------------------
93
167
 
94
- // One tab per app (each app is its own table). Shared by the Views and
95
- // Trash panels: switching here switches both. ARIA tabs pattern with
96
- // automatic activation: arrow keys, Home and End move and select.
97
168
  const tableId = uniqueId('views-table');
98
- const appTabs = el('div', {
99
- className: 'app-tabs',
100
- attrs: { role: 'tablist', 'aria-label': t('appTabs.label') },
101
- });
102
- let focusAppOnRender = false;
103
-
104
- function selectApp(appId, { focus = false } = {}) {
105
- focusAppOnRender = focus;
106
- if (appId === context.appId()) {
107
- renderAppTabs();
108
- return;
109
- }
110
- context.setAppId(appId);
111
- }
112
-
113
- /** Every app's tab, led by one for all of them together. */
114
- function tabEntries() {
115
- const apps = context.apps();
116
- const sum = (key) => apps.reduce((total, app) => total + app[key], 0);
117
- return [
118
- {
119
- appId: ALL_APPS,
120
- label: t('appTabs.all'),
121
- active: sum('active'),
122
- deleted: sum('deleted'),
123
- modified: sum('modified'),
124
- available: true,
125
- },
126
- ...apps.map((app) => ({ ...app, label: app.appId })),
127
- ];
128
- }
129
-
130
- function renderAppTabs() {
131
- const current = context.appId();
132
- replaceChildren(appTabs, tabEntries().map((app) => {
133
- const selected = app.appId === current;
134
- const count = isTrash ? app.deleted : app.active;
135
- const countText = t(isTrash ? 'appTabs.deletedCount' : 'appTabs.activeCount', { count });
136
- return el('button', {
137
- className: `app-tab${selected ? ' active' : ''}${app.available ? '' : ' unavailable'}`,
138
- attrs: {
139
- type: 'button',
140
- role: 'tab',
141
- 'aria-selected': String(selected),
142
- 'aria-controls': tableId,
143
- 'aria-label': t('appTabs.tabName', { app: app.label, count: countText }),
144
- tabindex: selected ? '0' : '-1',
145
- title: app.available ? null : t('appTabs.unavailable', { app: app.appId }),
146
- },
147
- dataset: { appId: app.appId },
148
- on: { click: () => selectApp(app.appId) },
149
- }, [
150
- el('span', { className: 'prompt-char', text: '❯', attrs: { 'aria-hidden': 'true' } }),
151
- el('span', { className: 'app-tab-name', text: app.label }),
152
- el('span', { className: 'app-tab-count', text: formatNumber(count), attrs: { 'aria-hidden': 'true' } }),
153
- ]);
154
- }));
155
- if (focusAppOnRender) {
156
- appTabs.querySelector('[aria-selected="true"]')?.focus();
157
- focusAppOnRender = false;
158
- }
159
- }
160
-
161
- appTabs.addEventListener('keydown', (event) => {
162
- const ids = tabEntries().map((app) => app.appId);
163
- if (ids.length === 0) return;
164
- const index = Math.max(0, ids.indexOf(context.appId()));
165
- const moves = {
166
- [KEY.ARROW_RIGHT]: (index + 1) % ids.length,
167
- [KEY.ARROW_LEFT]: (index - 1 + ids.length) % ids.length,
168
- [KEY.HOME]: 0,
169
- [KEY.END]: ids.length - 1,
170
- };
171
- if (!(event.key in moves)) return;
172
- event.preventDefault();
173
- selectApp(ids[moves[event.key]], { focus: true });
174
- });
169
+ const appTabs = createAppTabs({ context, controls: tableId, trash: isTrash });
175
170
 
176
171
  const searchInput = el('input', {
177
172
  className: 'input search-input',
@@ -218,7 +213,7 @@ export function createViewsPanel({ mode, meta, context }) {
218
213
 
219
214
  const eventTypeOptions = () => [
220
215
  { value: '', label: t('toolbar.allEventTypes') },
221
- ...[...state.knownEventTypes].sort().map((type) => ({ value: type, label: tOr(`eventTypes.${type}`, type) })),
216
+ ...state.knownEventTypes.map((type) => ({ value: type, label: tOr(`eventTypes.${type}`, type) })),
222
217
  ];
223
218
  const eventTypePicker = createListbox({
224
219
  label: t('toolbar.eventType'),
@@ -242,11 +237,33 @@ export function createViewsPanel({ mode, meta, context }) {
242
237
  },
243
238
  });
244
239
 
240
+ // Filters carried over from the Overview, each removable on its own.
241
+ const chips = el('div', { className: 'filter-chips', attrs: { 'aria-live': 'polite' } });
242
+ function renderChips() {
243
+ replaceChildren(chips, Object.entries(state.where).map(([dim, value]) => {
244
+ const label = `${t(`overview.dims.${dim}`)}: ${valueLabel(dim, value)}`;
245
+ return el('button', {
246
+ className: 'chip',
247
+ attrs: { type: 'button', 'aria-label': t('overview.removeFilter', { filter: label }), title: t('overview.removeFilter', { filter: label }) },
248
+ dataset: { dim },
249
+ on: { click: () => {
250
+ state.where = Object.fromEntries(Object.entries(state.where).filter(([key]) => key !== dim));
251
+ resetAndLoad();
252
+ } },
253
+ }, [icon('filter'), el('span', { className: 'chip-text', text: label }), icon('close')]);
254
+ }));
255
+ }
256
+
257
+ // Filled once the table exists: its "Columns" button sits with the other
258
+ // controls that change only how the rows are shown.
259
+ const columnsSlot = el('span', { className: 'toolbar-slot' });
260
+
245
261
  const toolbar = el('div', { className: 'toolbar' }, [
246
262
  el('div', { className: 'toolbar-group' }, [
247
263
  rangeFilter.element,
248
264
  isTrash ? null : eventTypePicker.element,
249
265
  searchInput,
266
+ chips,
250
267
  ]),
251
268
  el('div', { className: 'toolbar-group' }, [
252
269
  modifiedFilter.element,
@@ -256,6 +273,7 @@ export function createViewsPanel({ mode, meta, context }) {
256
273
  el('span', { className: 'switch-label', text: t('toolbar.showDeleted') }),
257
274
  ]),
258
275
  pageSizePicker.element,
276
+ columnsSlot,
259
277
  ]),
260
278
  ]);
261
279
 
@@ -272,7 +290,6 @@ export function createViewsPanel({ mode, meta, context }) {
272
290
  });
273
291
 
274
292
  const batchBar = el('div', { className: 'batch-bar', attrs: { role: 'region', 'aria-label': t('batch.region') } }, [
275
- el('span', { className: 'prompt-char', text: '❯', attrs: { 'aria-hidden': 'true' } }),
276
293
  batchCount,
277
294
  el('div', { className: 'batch-actions' }, [
278
295
  isTrash ? null : batchButton('batch.edit', 'secondary', () => editViews(selectedViews())),
@@ -294,9 +311,34 @@ export function createViewsPanel({ mode, meta, context }) {
294
311
  attrs: { type: 'checkbox', 'aria-label': t('table.selectPage') },
295
312
  on: { change: () => togglePage(selectAll.checked) },
296
313
  });
297
- const thead = el('thead');
298
- const tbody = el('tbody');
299
- const table = el('table', { className: 'data-table views-table', attrs: { id: tableId } }, [thead, tbody]);
314
+
315
+ const allApps = () => context.appId() === ALL_APPS;
316
+ const grid = createDataTable({
317
+ storageKey: mode,
318
+ id: tableId,
319
+ className: 'views-table',
320
+ // The trash leads with when a view was deleted; elsewhere that is one more column to choose.
321
+ columns: VIEW_COLUMNS.map(({ sort, allAppsOnly, trashOnly, cell, ...column }) => ({
322
+ ...column,
323
+ label: t(`columns.${column.id}`),
324
+ hidden: trashOnly ? !isTrash : column.hidden,
325
+ sortKey: meta.sortFields.includes(sort) ? sort : undefined,
326
+ available: allAppsOnly ? allApps : undefined,
327
+ unavailableNote: allAppsOnly ? t('table.onlyAllApps') : undefined,
328
+ cell: column.id === 'status' ? statusCell : cell,
329
+ })),
330
+ rowKey: (view) => view.id,
331
+ rowAttrs: (view) => ({ className: isDeleted(view) ? 'row-deleted' : '', dataset: { viewId: view.id } }),
332
+ select: { header: () => selectAll, cell: selectCell },
333
+ actions: { label: t('columns.actions'), width: ACTIONS_WIDTH, cell: actionsCell },
334
+ sort: () => ({ key: state.sort, order: state.order }),
335
+ onSort,
336
+ empty: () => (isTrash ? t('table.emptyTrash') : t('table.empty')),
337
+ });
338
+ const { table } = grid;
339
+ grid.onRender(renderSelection);
340
+ columnsSlot.append(grid.chooserButton);
341
+
300
342
  const pager = createPager((page) => {
301
343
  state.page = page;
302
344
  load();
@@ -304,33 +346,20 @@ export function createViewsPanel({ mode, meta, context }) {
304
346
 
305
347
  const retention = isTrash ? el('p', { className: 'notice' }) : null;
306
348
 
307
- // Insights live on the Views tab; the trash is for recovery, not analysis.
308
- const insights = isTrash ? null : createInsightsPanel({
309
- reportError: context.reportError,
310
- onData: (data) => {
311
- // The server lists every event type these apps hold, whatever the
312
- // other filters say, so choosing one never hides the others. The
313
- // current choice stays on offer even if its last view was edited.
314
- const types = new Set(data.eventTypes);
315
- if (state.eventType) types.add(state.eventType);
316
- const known = state.knownEventTypes;
317
- if (types.size === known.size && [...types].every((type) => known.has(type))) return;
318
- state.knownEventTypes = types;
319
- eventTypePicker.setOptions(eventTypeOptions(), state.eventType);
320
- },
321
- });
322
-
323
349
  const element = el('section', {
324
350
  className: 'panel',
325
351
  attrs: { 'aria-label': isTrash ? t('tabs.trash') : t('tabs.views') },
326
352
  }, [
327
- appTabs,
353
+ el('header', { className: 'panel-header' }, [
354
+ el('h2', { className: 'panel-title', text: isTrash ? t('tabs.trash') : t('tabs.views') }),
355
+ el('p', { className: 'panel-intro', text: isTrash ? t('trash.intro') : t('views.intro') }),
356
+ ]),
357
+ appTabs.element,
328
358
  toolbar,
329
359
  retention,
330
- insights?.element,
331
360
  summary,
332
361
  batchBar,
333
- el('div', { className: 'table-wrap' }, [table]),
362
+ grid.element,
334
363
  pager.element,
335
364
  ]);
336
365
 
@@ -380,35 +409,21 @@ export function createViewsPanel({ mode, meta, context }) {
380
409
  load();
381
410
  }
382
411
 
383
- function renderHead() {
384
- replaceChildren(thead, [el('tr', {}, [
385
- el('th', { className: 'col-select', attrs: { scope: 'col' } }, [selectAll]),
386
- ...COLUMNS.map((column) => headerCell({
387
- label: t(`columns.${column.key}`),
388
- sortKey: column.sort,
389
- sort: state.sort,
390
- order: state.order,
391
- onSort,
392
- className: column.className,
393
- })),
394
- ])]);
395
- }
412
+ const isDeleted = (view) => view.deletedAt !== null && view.deletedAt !== undefined;
396
413
 
397
414
  function badge(key, className, title) {
398
415
  return el('span', { className: `badge ${className}`, text: t(key), attrs: title ? { title } : {} });
399
416
  }
400
417
 
401
- function actionButton(icon, labelKey, handler, variant = '') {
418
+ function actionButton(iconName, labelKey, handler, variant = '') {
402
419
  return el('button', {
403
420
  className: `icon-btn row-action ${variant}`.trim(),
404
- text: icon,
405
421
  attrs: { type: 'button', 'aria-label': t(labelKey), title: t(labelKey) },
406
422
  on: { click: handler },
407
- });
423
+ }, [icon(iconName)]);
408
424
  }
409
425
 
410
- function renderRow(view) {
411
- const deleted = view.deletedAt !== null && view.deletedAt !== undefined;
426
+ function selectCell(view) {
412
427
  const checkbox = el('input', {
413
428
  attrs: {
414
429
  type: 'checkbox',
@@ -417,50 +432,28 @@ export function createViewsPanel({ mode, meta, context }) {
417
432
  on: { change: (event) => toggleRow(view, event.currentTarget.checked) },
418
433
  });
419
434
  checkbox.checked = state.selection.has(view.id);
435
+ return checkbox;
436
+ }
420
437
 
421
- const badges = [
422
- deleted ? badge('status.deleted', 'badge-danger', formatDateTime(view.deletedAt)) : null,
438
+ function statusCell(view) {
439
+ return [el('div', { className: 'badges' }, [
440
+ isDeleted(view) ? badge('status.deleted', 'badge-danger', formatDateTime(view.deletedAt)) : null,
423
441
  view.adminModifiedAt ? badge('status.modified', 'badge-warning', formatDateTime(view.adminModifiedAt)) : null,
424
442
  view.note ? badge('status.note', 'badge-info', view.note) : null,
425
443
  view.isUnique ? null : badge('status.repeat', 'badge-muted'),
426
- ];
444
+ ])];
445
+ }
427
446
 
428
- const actions = [
429
- actionButton('ⓘ', 'rowActions.details', () => openDetails(view)),
430
- deleted ? null : actionButton('✎', 'rowActions.edit', () => editViews([view])),
431
- actionButton('✐', 'rowActions.note', () => noteViews([view])),
447
+ function actionsCell(view) {
448
+ const deleted = isDeleted(view);
449
+ return el('div', { className: 'row-actions' }, [
450
+ actionButton('info', 'rowActions.details', () => openDetails(view)),
451
+ deleted ? null : actionButton('edit', 'rowActions.edit', () => editViews([view])),
452
+ actionButton('note', 'rowActions.note', () => noteViews([view])),
432
453
  deleted
433
- ? actionButton('↺', 'rowActions.restore', () => restoreViews([view]))
434
- : actionButton('🗑', 'rowActions.delete', () => deleteViews([view]), 'danger'),
435
- deleted && isTrash ? actionButton('⨯', 'rowActions.purge', () => purgeViews([view]), 'danger') : null,
436
- ];
437
-
438
- const source = view.sourceType ? tOr(`sources.${view.sourceType}`, view.sourceType) : t('common.none');
439
-
440
- return el('tr', { className: deleted ? 'row-deleted' : '', dataset: { viewId: view.id } }, [
441
- el('td', { className: 'col-select' }, [checkbox]),
442
- timeCell(view.timestamp),
443
- el('td', { className: 'col-app' }, [clampText(view.appId, { className: 'mono' })]),
444
- el('td', { className: 'col-page' }, [
445
- clampText(orNone(view.pagePath), { className: 'cell-primary' }),
446
- clampText(orNone(view.pageTitle), { lines: CLAMP_LINES.SINGLE, className: 'cell-secondary' }),
447
- ]),
448
- el('td', { className: 'col-source' }, [
449
- clampText(source, { className: 'cell-primary' }),
450
- clampText(orNone(view.referrerDomain), { className: 'cell-secondary' }),
451
- ]),
452
- el('td', { className: 'col-device' }, [
453
- clampText(view.deviceSize ? tOr(`deviceSizes.${view.deviceSize}`, view.deviceSize) : t('common.none'), { className: 'cell-primary' }),
454
- clampText(orNone(view.deviceType), { className: 'cell-secondary' }),
455
- ]),
456
- el('td', { className: 'col-country' }, [clampText(orNone(view.country))]),
457
- el('td', { className: 'col-client' }, [
458
- clampText(orNone(view.browser), { className: 'cell-primary' }),
459
- clampText(orNone(view.os), { className: 'cell-secondary' }),
460
- ]),
461
- el('td', { className: 'col-event' }, [clampText(orNone(view.eventType))]),
462
- el('td', { className: 'col-status' }, [el('div', { className: 'badges' }, badges)]),
463
- el('td', { className: 'col-actions' }, [el('div', { className: 'row-actions' }, actions)]),
454
+ ? actionButton('restore', 'rowActions.restore', () => restoreViews([view]))
455
+ : actionButton('trash', 'rowActions.delete', () => deleteViews([view]), 'danger'),
456
+ deleted && isTrash ? actionButton('purge', 'rowActions.purge', () => purgeViews([view]), 'danger') : null,
464
457
  ]);
465
458
  }
466
459
 
@@ -475,7 +468,7 @@ export function createViewsPanel({ mode, meta, context }) {
475
468
  selectAll.indeterminate = selectedOnPage > 0 && selectedOnPage < pageIds.length;
476
469
  selectAll.disabled = pageIds.length === 0;
477
470
 
478
- for (const row of tbody.querySelectorAll('tr[data-view-id]')) {
471
+ for (const row of table.querySelectorAll('tbody tr[data-view-id]')) {
479
472
  const checkbox = row.querySelector('.col-select input');
480
473
  if (checkbox) checkbox.checked = state.selection.has(row.dataset.viewId);
481
474
  }
@@ -486,20 +479,14 @@ export function createViewsPanel({ mode, meta, context }) {
486
479
 
487
480
  /** Rebuild the table from state. After a sort, focus returns to its header. */
488
481
  function render() {
489
- renderHead();
490
- if (state.rows.length === 0) {
491
- replaceChildren(tbody, [messageRow(COLUMN_COUNT, isTrash ? t('table.emptyTrash') : t('table.empty'))]);
492
- } else {
493
- replaceChildren(tbody, state.rows.map(renderRow));
494
- }
495
- renderSelection();
482
+ grid.setRows(state.rows);
496
483
  pager.update(state.page, state.pageSize, state.total);
497
484
  if (state.focusSort) {
498
- thead.querySelector(`[data-sort-key="${state.focusSort}"]`)?.focus();
485
+ grid.sortButton(state.focusSort)?.focus();
499
486
  state.focusSort = null;
500
487
  }
501
488
 
502
- const app = tabEntries().find((entry) => entry.appId === context.appId());
489
+ const app = appEntries(context.apps()).find((entry) => entry.appId === context.appId());
503
490
  summary.textContent = app
504
491
  ? t('views.summary', {
505
492
  active: formatNumber(app.active),
@@ -535,8 +522,6 @@ export function createViewsPanel({ mode, meta, context }) {
535
522
  if (seq !== state.requestSeq) return;
536
523
  state.rows = result.views;
537
524
  state.total = result.total;
538
- // Rows name their app only under All apps; one app's tab already does.
539
- table.classList.toggle('has-app', appId === ALL_APPS);
540
525
  if (state.rows.length === 0 && state.page > 1 && state.total > 0) {
541
526
  state.page = Math.max(1, Math.ceil(state.total / state.pageSize));
542
527
  await load();
@@ -550,16 +535,30 @@ export function createViewsPanel({ mode, meta, context }) {
550
535
  }
551
536
  }
552
537
 
553
- /** Refresh the insights for the current filters; the table has its own paging. */
554
- function loadInsights() {
555
- if (insights && context.appId()) insights.load({ appId: context.appId(), ...filters() });
538
+ /**
539
+ * Every event type the app holds in this status, whatever else is
540
+ * filtered, so choosing one never hides the others. The current choice
541
+ * stays on offer even if its last view was edited.
542
+ */
543
+ async function loadEventTypes() {
544
+ const appId = context.appId();
545
+ if (isTrash || !appId) return;
546
+ try {
547
+ const query = { status: status() };
548
+ const { eventTypes } = appId === ALL_APPS ? await api.eventTypesAll(query) : await api.eventTypes(appId, query);
549
+ if (appId !== context.appId()) return;
550
+ state.knownEventTypes = [...new Set([...eventTypes, ...(state.eventType ? [state.eventType] : [])])].sort();
551
+ eventTypePicker.setOptions(eventTypeOptions(), state.eventType);
552
+ } catch (error) {
553
+ context.reportError(error);
554
+ }
556
555
  }
557
556
 
558
557
  function resetAndLoad() {
559
558
  state.page = 1;
560
559
  state.selection.clear();
560
+ renderChips();
561
561
  load();
562
- loadInsights();
563
562
  }
564
563
 
565
564
  // ---- Operations --------------------------------------------------------
@@ -604,10 +603,11 @@ export function createViewsPanel({ mode, meta, context }) {
604
603
  }
605
604
  if (failure) context.reportError(failure);
606
605
  if (result.affected > 0 || !failure) {
606
+ context.viewsChanged();
607
607
  try {
608
608
  await context.refreshApps();
609
609
  await load();
610
- loadInsights();
610
+ loadEventTypes();
611
611
  } catch (error) {
612
612
  context.reportError(error);
613
613
  }
@@ -668,18 +668,84 @@ export function createViewsPanel({ mode, meta, context }) {
668
668
 
669
669
  render();
670
670
 
671
+ let visible = false;
672
+ let stale = true;
673
+
671
674
  return {
672
675
  element,
673
676
  /** The app list or selected app changed. */
674
677
  syncApps() {
675
- renderAppTabs();
678
+ appTabs.render();
676
679
  },
677
- /** Start over for the current app. */
678
- reload: resetAndLoad,
679
- /** Refresh the current page and the insights, keeping the selection. */
680
- refresh() {
681
- load();
682
- loadInsights();
680
+ /** The app changed: breakdown values of one app mean nothing in another. */
681
+ appChanged() {
682
+ state.where = {};
683
+ state.page = 1;
684
+ state.selection.clear();
685
+ stale = true;
686
+ if (visible) {
687
+ resetAndLoad();
688
+ loadEventTypes();
689
+ }
690
+ },
691
+ /** Shown: start over after an app change, otherwise refresh the page, keeping the selection. */
692
+ show() {
693
+ visible = true;
694
+ if (stale) {
695
+ stale = false;
696
+ resetAndLoad();
697
+ } else {
698
+ load();
699
+ }
700
+ loadEventTypes();
701
+ },
702
+ hide() {
703
+ visible = false;
704
+ },
705
+ /** Rows changed elsewhere (in the other of Views and Trash). */
706
+ invalidate() {
707
+ if (visible) load();
708
+ },
709
+ /**
710
+ * Show exactly the rows an Overview describes: its range, event type,
711
+ * and breakdown values. Search and the admin-changes filter start over.
712
+ * @param {{ range: string, eventType: string, where: Record<string, string|null> }} next
713
+ */
714
+ applyFilters({ range, eventType, where }) {
715
+ state.range = range;
716
+ state.eventType = eventType;
717
+ state.where = { ...where };
718
+ state.search = '';
719
+ state.modified = MODIFIED_FILTER.ANY;
720
+ searchInput.value = '';
721
+ rangeFilter.setValue(range);
722
+ modifiedFilter.setValue(MODIFIED_FILTER.ANY);
723
+ if (eventType && !state.knownEventTypes.includes(eventType)) {
724
+ state.knownEventTypes = [...state.knownEventTypes, eventType].sort();
725
+ }
726
+ eventTypePicker.setOptions(eventTypeOptions(), eventType);
727
+ stale = true;
728
+ },
729
+ /**
730
+ * Show one view by its ID, wherever it is: live or in the trash, in
731
+ * any range. Every other filter starts over.
732
+ * @param {string} viewId
733
+ */
734
+ findView(viewId) {
735
+ state.range = RANGE.ALL;
736
+ state.eventType = '';
737
+ state.where = {};
738
+ state.modified = MODIFIED_FILTER.ANY;
739
+ state.search = viewId;
740
+ searchInput.value = viewId;
741
+ if (!isTrash) {
742
+ state.showDeleted = true;
743
+ showDeletedToggle.checked = true;
744
+ }
745
+ rangeFilter.setValue(RANGE.ALL);
746
+ modifiedFilter.setValue(MODIFIED_FILTER.ANY);
747
+ eventTypePicker.setOptions(eventTypeOptions(), '');
748
+ stale = true;
683
749
  },
684
750
  };
685
751
  }