@harshankur/viewcounter 3.0.1 → 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 (64) hide show
  1. package/.env.example +50 -6
  2. package/README.md +444 -104
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +2568 -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 +95 -0
  11. package/admin/js/api.js +146 -0
  12. package/admin/js/appTabs.js +100 -0
  13. package/admin/js/charts.js +842 -0
  14. package/admin/js/clamp.js +41 -0
  15. package/admin/js/constants.js +239 -0
  16. package/admin/js/dataTable.js +478 -0
  17. package/admin/js/dom.js +83 -0
  18. package/admin/js/format.js +130 -0
  19. package/admin/js/i18n.js +80 -0
  20. package/admin/js/icons.js +168 -0
  21. package/admin/js/listbox.js +145 -0
  22. package/admin/js/logs.js +318 -0
  23. package/admin/js/main.js +399 -0
  24. package/admin/js/modal.js +171 -0
  25. package/admin/js/overview.js +905 -0
  26. package/admin/js/passwordPrompt.js +75 -0
  27. package/admin/js/table.js +94 -0
  28. package/admin/js/theme.js +72 -0
  29. package/admin/js/toast.js +47 -0
  30. package/admin/js/viewDialogs.js +224 -0
  31. package/admin/js/views.js +751 -0
  32. package/admin/locales/en.json +683 -0
  33. package/admin/site.webmanifest +20 -0
  34. package/config/index.js +122 -4
  35. package/constants.js +334 -3
  36. package/db/AdminRepository.js +488 -0
  37. package/db/DatabaseManager.js +148 -26
  38. package/db/LogRepository.js +354 -0
  39. package/db/adminSchema.js +329 -0
  40. package/db/adminSessionStore.js +104 -0
  41. package/db/analysis.js +479 -0
  42. package/db/rejectionCounter.js +117 -0
  43. package/db/retention.js +97 -0
  44. package/index.js +91 -24
  45. package/middleware/adminAuth.js +244 -0
  46. package/middleware/adminValidation.js +319 -0
  47. package/middleware/auth.js +2 -2
  48. package/middleware/security.js +26 -2
  49. package/middleware/validation.js +50 -2
  50. package/package.json +20 -10
  51. package/routes/admin.js +546 -0
  52. package/routes/analytics.js +207 -19
  53. package/tracker/tracker.js +191 -0
  54. package/utils/appIdUtils.js +1 -1
  55. package/utils/cookieUtils.js +47 -0
  56. package/utils/durationUtils.js +33 -0
  57. package/utils/errorUtils.js +39 -1
  58. package/utils/geoCity.js +87 -0
  59. package/utils/ipUtils.js +1 -1
  60. package/utils/privacyUtils.js +2 -2
  61. package/utils/referrerParser.js +23 -5
  62. package/utils/secretStore.js +1 -1
  63. package/utils/userAgentParser.js +52 -3
  64. package/utils/visitorContext.js +70 -0
@@ -0,0 +1,751 @@
1
+ /**
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). These
4
+ * rows are the data every statistic on the Overview is computed from.
5
+ *
6
+ * Selection is a set of view IDs that survives paging, so a batch can span
7
+ * pages, and is cleared whenever the query changes shape (app, filter,
8
+ * search) so an action can never reach rows the admin can no longer see.
9
+ */
10
+
11
+ import { api } from './api.js';
12
+ import { appEntries, createAppTabs } from './appTabs.js';
13
+ import { regionName } from './charts.js';
14
+ import { clampText } from './clamp.js';
15
+ import { createDataTable } from './dataTable.js';
16
+ import { el, replaceChildren, debounce, uniqueId } from './dom.js';
17
+ import { formatDate, formatDateTime, formatDuration, formatNumber, formatTime, orNone } from './format.js';
18
+ import { icon } from './icons.js';
19
+ import { t, tOr } from './i18n.js';
20
+ import { createListbox } from './listbox.js';
21
+ import { confirmModal } from './modal.js';
22
+ import { createPager, createSegmented } from './table.js';
23
+ import { showToast, TOAST_TYPE } from './toast.js';
24
+ import { openDetails, openEditor, openNoteEditor } from './viewDialogs.js';
25
+ import { valueLabel } from './overview.js';
26
+ import {
27
+ ALL_APPS,
28
+ RANGE,
29
+ MODIFIED_FILTER,
30
+ SEARCH_DEBOUNCE_MS,
31
+ SORT_ORDER,
32
+ VIEW_STATUS,
33
+ } from './constants.js';
34
+
35
+ /** Which rows a panel shows. */
36
+ export const PANEL_MODE = Object.freeze({
37
+ VIEWS: 'views',
38
+ TRASH: 'trash',
39
+ });
40
+
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) },
122
+ ];
123
+
124
+ /**
125
+ * @param {{ mode: string, meta: object, context: { apps: () => object[], appId: () => string,
126
+ * setAppId: (id: string) => void, refreshApps: () => Promise<void>, reportError: (error: unknown) => void,
127
+ * viewsChanged: () => void } }} deps
128
+ */
129
+ export function createViewsPanel({ mode, meta, context }) {
130
+ const isTrash = mode === PANEL_MODE.TRASH;
131
+ const state = {
132
+ showDeleted: false,
133
+ modified: MODIFIED_FILTER.ANY,
134
+ range: RANGE.ALL,
135
+ eventType: '',
136
+ knownEventTypes: [],
137
+ /** Breakdown values carried over from the Overview, such as one country. */
138
+ where: {},
139
+ search: '',
140
+ sort: isTrash ? 'deletedAt' : 'timestamp',
141
+ order: SORT_ORDER.DESC,
142
+ page: 1,
143
+ pageSize: meta.pageSizeDefault,
144
+ total: 0,
145
+ rows: [],
146
+ selection: new Map(),
147
+ requestSeq: 0,
148
+ focusSort: null,
149
+ };
150
+
151
+ const status = () => {
152
+ if (isTrash) return VIEW_STATUS.DELETED;
153
+ return state.showDeleted ? VIEW_STATUS.ALL : VIEW_STATUS.ACTIVE;
154
+ };
155
+
156
+ /** The filters of the listing, the same the Overview's analysis takes. */
157
+ const filters = () => ({
158
+ status: status(),
159
+ modified: state.modified,
160
+ range: state.range,
161
+ eventType: state.eventType,
162
+ where: state.where,
163
+ search: state.search,
164
+ });
165
+
166
+ // ---- Toolbar -----------------------------------------------------------
167
+
168
+ const tableId = uniqueId('views-table');
169
+ const appTabs = createAppTabs({ context, controls: tableId, trash: isTrash });
170
+
171
+ const searchInput = el('input', {
172
+ className: 'input search-input',
173
+ attrs: {
174
+ type: 'search',
175
+ placeholder: t('toolbar.searchPlaceholder'),
176
+ 'aria-label': t('toolbar.search'),
177
+ maxlength: meta.searchMaxLength,
178
+ },
179
+ on: { input: debounce(() => {
180
+ state.search = searchInput.value.trim();
181
+ resetAndLoad();
182
+ }, SEARCH_DEBOUNCE_MS) },
183
+ });
184
+
185
+ const modifiedFilter = createSegmented({
186
+ label: t('toolbar.modifiedFilter'),
187
+ value: MODIFIED_FILTER.ANY,
188
+ options: Object.values(MODIFIED_FILTER).map((value) => ({ value, label: t(`modifiedFilter.${value}`) })),
189
+ onChange: (value) => {
190
+ state.modified = value;
191
+ resetAndLoad();
192
+ },
193
+ });
194
+
195
+ const showDeletedToggle = el('input', {
196
+ className: 'switch-input',
197
+ attrs: { type: 'checkbox', role: 'switch' },
198
+ on: { change: () => {
199
+ state.showDeleted = showDeletedToggle.checked;
200
+ resetAndLoad();
201
+ } },
202
+ });
203
+
204
+ const rangeFilter = createSegmented({
205
+ label: t('toolbar.range'),
206
+ value: RANGE.ALL,
207
+ options: meta.ranges.map((value) => ({ value, label: t(`ranges.${value}`) })),
208
+ onChange: (value) => {
209
+ state.range = value;
210
+ resetAndLoad();
211
+ },
212
+ });
213
+
214
+ const eventTypeOptions = () => [
215
+ { value: '', label: t('toolbar.allEventTypes') },
216
+ ...state.knownEventTypes.map((type) => ({ value: type, label: tOr(`eventTypes.${type}`, type) })),
217
+ ];
218
+ const eventTypePicker = createListbox({
219
+ label: t('toolbar.eventType'),
220
+ options: eventTypeOptions(),
221
+ value: '',
222
+ className: 'event-type-picker',
223
+ onChange: (value) => {
224
+ state.eventType = value;
225
+ resetAndLoad();
226
+ },
227
+ });
228
+
229
+ const pageSizePicker = createListbox({
230
+ label: t('toolbar.pageSize'),
231
+ options: meta.pageSizes.map((size) => ({ value: String(size), label: t('toolbar.perPage', { count: size }) })),
232
+ value: String(meta.pageSizeDefault),
233
+ onChange: (value) => {
234
+ state.pageSize = Number(value);
235
+ state.page = 1;
236
+ load();
237
+ },
238
+ });
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
+
261
+ const toolbar = el('div', { className: 'toolbar' }, [
262
+ el('div', { className: 'toolbar-group' }, [
263
+ rangeFilter.element,
264
+ isTrash ? null : eventTypePicker.element,
265
+ searchInput,
266
+ chips,
267
+ ]),
268
+ el('div', { className: 'toolbar-group' }, [
269
+ modifiedFilter.element,
270
+ isTrash ? null : el('label', { className: 'switch' }, [
271
+ showDeletedToggle,
272
+ el('span', { className: 'switch-track', attrs: { 'aria-hidden': 'true' } }),
273
+ el('span', { className: 'switch-label', text: t('toolbar.showDeleted') }),
274
+ ]),
275
+ pageSizePicker.element,
276
+ columnsSlot,
277
+ ]),
278
+ ]);
279
+
280
+ const summary = el('p', { className: 'panel-summary', attrs: { 'aria-live': 'polite' } });
281
+
282
+ // ---- Batch bar ---------------------------------------------------------
283
+
284
+ const batchCount = el('span', { className: 'batch-count' });
285
+ const batchButton = (key, variant, handler) => el('button', {
286
+ className: `btn btn-${variant} btn-small`,
287
+ text: t(key),
288
+ attrs: { type: 'button' },
289
+ on: { click: handler },
290
+ });
291
+
292
+ const batchBar = el('div', { className: 'batch-bar', attrs: { role: 'region', 'aria-label': t('batch.region') } }, [
293
+ batchCount,
294
+ el('div', { className: 'batch-actions' }, [
295
+ isTrash ? null : batchButton('batch.edit', 'secondary', () => editViews(selectedViews())),
296
+ batchButton('batch.note', 'secondary', () => noteViews(selectedViews())),
297
+ isTrash ? null : batchButton('batch.delete', 'danger', () => deleteViews(selectedViews())),
298
+ batchButton('batch.restore', 'secondary', () => restoreViews(selectedViews())),
299
+ isTrash ? batchButton('batch.purge', 'danger', () => purgeViews(selectedViews())) : null,
300
+ batchButton('batch.clear', 'ghost', () => {
301
+ state.selection.clear();
302
+ renderSelection();
303
+ }),
304
+ ]),
305
+ ]);
306
+ batchBar.hidden = true;
307
+
308
+ // ---- Table -------------------------------------------------------------
309
+
310
+ const selectAll = el('input', {
311
+ attrs: { type: 'checkbox', 'aria-label': t('table.selectPage') },
312
+ on: { change: () => togglePage(selectAll.checked) },
313
+ });
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
+
342
+ const pager = createPager((page) => {
343
+ state.page = page;
344
+ load();
345
+ });
346
+
347
+ const retention = isTrash ? el('p', { className: 'notice' }) : null;
348
+
349
+ const element = el('section', {
350
+ className: 'panel',
351
+ attrs: { 'aria-label': isTrash ? t('tabs.trash') : t('tabs.views') },
352
+ }, [
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,
358
+ toolbar,
359
+ retention,
360
+ summary,
361
+ batchBar,
362
+ grid.element,
363
+ pager.element,
364
+ ]);
365
+
366
+ // ---- Selection ---------------------------------------------------------
367
+
368
+ function selectedViews() {
369
+ return [...state.selection.values()];
370
+ }
371
+
372
+ function toggleRow(view, checked) {
373
+ if (checked && !state.selection.has(view.id) && state.selection.size >= meta.maxBatchIds) {
374
+ showToast(t('batch.limit', { count: meta.maxBatchIds }), TOAST_TYPE.ERROR);
375
+ renderSelection();
376
+ return;
377
+ }
378
+ if (checked) state.selection.set(view.id, view);
379
+ else state.selection.delete(view.id);
380
+ renderSelection();
381
+ }
382
+
383
+ function togglePage(checked) {
384
+ for (const view of state.rows) {
385
+ if (!checked) {
386
+ state.selection.delete(view.id);
387
+ } else if (!state.selection.has(view.id)) {
388
+ if (state.selection.size >= meta.maxBatchIds) {
389
+ showToast(t('batch.limit', { count: meta.maxBatchIds }), TOAST_TYPE.ERROR);
390
+ break;
391
+ }
392
+ state.selection.set(view.id, view);
393
+ }
394
+ }
395
+ renderSelection();
396
+ }
397
+
398
+ // ---- Rendering ---------------------------------------------------------
399
+
400
+ function onSort(key) {
401
+ state.focusSort = key;
402
+ if (state.sort === key) {
403
+ state.order = state.order === SORT_ORDER.ASC ? SORT_ORDER.DESC : SORT_ORDER.ASC;
404
+ } else {
405
+ state.sort = key;
406
+ state.order = SORT_ORDER.DESC;
407
+ }
408
+ state.page = 1;
409
+ load();
410
+ }
411
+
412
+ const isDeleted = (view) => view.deletedAt !== null && view.deletedAt !== undefined;
413
+
414
+ function badge(key, className, title) {
415
+ return el('span', { className: `badge ${className}`, text: t(key), attrs: title ? { title } : {} });
416
+ }
417
+
418
+ function actionButton(iconName, labelKey, handler, variant = '') {
419
+ return el('button', {
420
+ className: `icon-btn row-action ${variant}`.trim(),
421
+ attrs: { type: 'button', 'aria-label': t(labelKey), title: t(labelKey) },
422
+ on: { click: handler },
423
+ }, [icon(iconName)]);
424
+ }
425
+
426
+ function selectCell(view) {
427
+ const checkbox = el('input', {
428
+ attrs: {
429
+ type: 'checkbox',
430
+ 'aria-label': t('table.selectRow', { time: formatDateTime(view.timestamp) }),
431
+ },
432
+ on: { change: (event) => toggleRow(view, event.currentTarget.checked) },
433
+ });
434
+ checkbox.checked = state.selection.has(view.id);
435
+ return checkbox;
436
+ }
437
+
438
+ function statusCell(view) {
439
+ return [el('div', { className: 'badges' }, [
440
+ isDeleted(view) ? badge('status.deleted', 'badge-danger', formatDateTime(view.deletedAt)) : null,
441
+ view.adminModifiedAt ? badge('status.modified', 'badge-warning', formatDateTime(view.adminModifiedAt)) : null,
442
+ view.note ? badge('status.note', 'badge-info', view.note) : null,
443
+ view.isUnique ? null : badge('status.repeat', 'badge-muted'),
444
+ ])];
445
+ }
446
+
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])),
453
+ deleted
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,
457
+ ]);
458
+ }
459
+
460
+ /**
461
+ * Reflect the selection without rebuilding any row, so the checkbox the
462
+ * admin just used keeps keyboard focus.
463
+ */
464
+ function renderSelection() {
465
+ const pageIds = state.rows.map((view) => view.id);
466
+ const selectedOnPage = pageIds.filter((id) => state.selection.has(id)).length;
467
+ selectAll.checked = pageIds.length > 0 && selectedOnPage === pageIds.length;
468
+ selectAll.indeterminate = selectedOnPage > 0 && selectedOnPage < pageIds.length;
469
+ selectAll.disabled = pageIds.length === 0;
470
+
471
+ for (const row of table.querySelectorAll('tbody tr[data-view-id]')) {
472
+ const checkbox = row.querySelector('.col-select input');
473
+ if (checkbox) checkbox.checked = state.selection.has(row.dataset.viewId);
474
+ }
475
+
476
+ batchBar.hidden = state.selection.size === 0;
477
+ batchCount.textContent = t('batch.selected', { count: state.selection.size });
478
+ }
479
+
480
+ /** Rebuild the table from state. After a sort, focus returns to its header. */
481
+ function render() {
482
+ grid.setRows(state.rows);
483
+ pager.update(state.page, state.pageSize, state.total);
484
+ if (state.focusSort) {
485
+ grid.sortButton(state.focusSort)?.focus();
486
+ state.focusSort = null;
487
+ }
488
+
489
+ const app = appEntries(context.apps()).find((entry) => entry.appId === context.appId());
490
+ summary.textContent = app
491
+ ? t('views.summary', {
492
+ active: formatNumber(app.active),
493
+ modified: formatNumber(app.modified),
494
+ deleted: formatNumber(app.deleted),
495
+ count: app.active,
496
+ })
497
+ : '';
498
+ if (retention) {
499
+ retention.textContent = meta.trashRetentionDays > 0
500
+ ? t('trash.retention', { count: meta.trashRetentionDays })
501
+ : t('trash.retentionOff');
502
+ }
503
+ }
504
+
505
+ // ---- Loading -----------------------------------------------------------
506
+
507
+ async function load() {
508
+ const appId = context.appId();
509
+ if (!appId) return;
510
+ const seq = ++state.requestSeq;
511
+ table.setAttribute('aria-busy', 'true');
512
+ try {
513
+ const query = {
514
+ ...filters(),
515
+ sort: state.sort,
516
+ order: state.order,
517
+ page: state.page,
518
+ pageSize: state.pageSize,
519
+ };
520
+ const result = appId === ALL_APPS ? await api.allViews(query) : await api.views(appId, query);
521
+ // A slower, older response must never overwrite a newer one.
522
+ if (seq !== state.requestSeq) return;
523
+ state.rows = result.views;
524
+ state.total = result.total;
525
+ if (state.rows.length === 0 && state.page > 1 && state.total > 0) {
526
+ state.page = Math.max(1, Math.ceil(state.total / state.pageSize));
527
+ await load();
528
+ return;
529
+ }
530
+ render();
531
+ } catch (error) {
532
+ if (seq === state.requestSeq) context.reportError(error);
533
+ } finally {
534
+ if (seq === state.requestSeq) table.removeAttribute('aria-busy');
535
+ }
536
+ }
537
+
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
+ }
555
+ }
556
+
557
+ function resetAndLoad() {
558
+ state.page = 1;
559
+ state.selection.clear();
560
+ renderChips();
561
+ load();
562
+ }
563
+
564
+ // ---- Operations --------------------------------------------------------
565
+
566
+ /**
567
+ * Run a mutation, report the outcome, and refresh. The server returns the
568
+ * IDs it actually changed, which may be fewer than requested (a row
569
+ * already restored, or already erased by someone else).
570
+ */
571
+ async function mutate(views, run, successKey) {
572
+ // Grouped by app, since each app is its own table: a selection made
573
+ // under "All apps" can span several.
574
+ const byApp = new Map();
575
+ for (const view of views) byApp.set(view.appId, [...(byApp.get(view.appId) || []), view.id]);
576
+ // Each app is its own request and its own transaction. One that fails
577
+ // must not hide what the others already did: those rows are changed
578
+ // for real, so they leave the selection and the table is refreshed.
579
+ const result = { attempted: 0, affected: 0, ids: [] };
580
+ let failure = null;
581
+ for (const [appId, ids] of byApp) {
582
+ try {
583
+ const part = await run(appId, ids);
584
+ result.attempted += ids.length;
585
+ result.affected += part.affected;
586
+ result.ids.push(...part.ids);
587
+ } catch (error) {
588
+ failure ??= error;
589
+ }
590
+ }
591
+ for (const id of result.ids) state.selection.delete(id);
592
+
593
+ if (result.attempted > 0) {
594
+ // Skipped means refused for a reason the server gave; rows whose
595
+ // request failed are covered by the error instead.
596
+ const skipped = result.attempted - result.affected;
597
+ showToast(
598
+ skipped > 0
599
+ ? t(`${successKey}Partial`, { count: result.affected, skipped })
600
+ : t(successKey, { count: result.affected }),
601
+ TOAST_TYPE.SUCCESS,
602
+ );
603
+ }
604
+ if (failure) context.reportError(failure);
605
+ if (result.affected > 0 || !failure) {
606
+ context.viewsChanged();
607
+ try {
608
+ await context.refreshApps();
609
+ await load();
610
+ loadEventTypes();
611
+ } catch (error) {
612
+ context.reportError(error);
613
+ }
614
+ }
615
+ return !failure;
616
+ }
617
+
618
+ function editViews(views) {
619
+ if (views.length === 0) return;
620
+ openEditor({
621
+ views,
622
+ meta,
623
+ onSubmit: (changes) => mutate(views, (appId, ids) => api.edit(appId, ids, changes), 'toasts.edited'),
624
+ });
625
+ }
626
+
627
+ function noteViews(views) {
628
+ if (views.length === 0) return;
629
+ openNoteEditor({
630
+ views,
631
+ meta,
632
+ onSubmit: (note) => mutate(
633
+ views,
634
+ (appId, ids) => api.note(appId, ids, note),
635
+ note === null ? 'toasts.noteCleared' : 'toasts.noteSaved',
636
+ ),
637
+ });
638
+ }
639
+
640
+ async function deleteViews(views) {
641
+ if (views.length === 0) return;
642
+ const confirmed = await confirmModal({
643
+ title: t('confirm.deleteTitle', { count: views.length }),
644
+ message: meta.trashRetentionDays > 0
645
+ ? t('confirm.deleteMessage', { count: views.length, days: meta.trashRetentionDays })
646
+ : t('confirm.deleteMessageNoRetention', { count: views.length }),
647
+ confirmLabel: t('confirm.deleteAction'),
648
+ danger: true,
649
+ });
650
+ if (confirmed) await mutate(views, api.remove, 'toasts.deleted');
651
+ }
652
+
653
+ async function restoreViews(views) {
654
+ if (views.length === 0) return;
655
+ await mutate(views, api.restore, 'toasts.restored');
656
+ }
657
+
658
+ async function purgeViews(views) {
659
+ if (views.length === 0) return;
660
+ const confirmed = await confirmModal({
661
+ title: t('confirm.purgeTitle', { count: views.length }),
662
+ message: t('confirm.purgeMessage', { count: views.length }),
663
+ confirmLabel: t('confirm.purgeAction'),
664
+ danger: true,
665
+ });
666
+ if (confirmed) await mutate(views, api.purge, 'toasts.purged');
667
+ }
668
+
669
+ render();
670
+
671
+ let visible = false;
672
+ let stale = true;
673
+
674
+ return {
675
+ element,
676
+ /** The app list or selected app changed. */
677
+ syncApps() {
678
+ appTabs.render();
679
+ },
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;
749
+ },
750
+ };
751
+ }