@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.
- package/.env.example +50 -6
- package/README.md +444 -104
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +2568 -0
- package/admin/favicon.ico +0 -0
- package/admin/favicon.svg +9 -0
- package/admin/icon-192.png +0 -0
- package/admin/icon-512.png +0 -0
- package/admin/index.html +95 -0
- package/admin/js/api.js +146 -0
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +842 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +239 -0
- package/admin/js/dataTable.js +478 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +130 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +145 -0
- package/admin/js/logs.js +318 -0
- package/admin/js/main.js +399 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +94 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +224 -0
- package/admin/js/views.js +751 -0
- package/admin/locales/en.json +683 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +122 -4
- package/constants.js +334 -3
- package/db/AdminRepository.js +488 -0
- package/db/DatabaseManager.js +148 -26
- package/db/LogRepository.js +354 -0
- package/db/adminSchema.js +329 -0
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +479 -0
- package/db/rejectionCounter.js +117 -0
- package/db/retention.js +97 -0
- package/index.js +91 -24
- package/middleware/adminAuth.js +244 -0
- package/middleware/adminValidation.js +319 -0
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +20 -10
- package/routes/admin.js +546 -0
- package/routes/analytics.js +207 -19
- package/tracker/tracker.js +191 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/cookieUtils.js +47 -0
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +39 -1
- package/utils/geoCity.js +87 -0
- package/utils/ipUtils.js +1 -1
- package/utils/privacyUtils.js +2 -2
- package/utils/referrerParser.js +23 -5
- package/utils/secretStore.js +1 -1
- package/utils/userAgentParser.js +52 -3
- package/utils/visitorContext.js +70 -0
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Overview: every statistic for the app and period chosen at the top,
|
|
3
|
+
* read from live views only (the trash never counts).
|
|
4
|
+
*
|
|
5
|
+
* Clicking a row in any breakdown narrows the whole page to that value, as a
|
|
6
|
+
* filter chip above; "Show these views" opens exactly those rows in the Views
|
|
7
|
+
* table. "Right now" refreshes itself while the Overview is on screen.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { api } from './api.js';
|
|
11
|
+
import { createAppTabs } from './appTabs.js';
|
|
12
|
+
import {
|
|
13
|
+
heatmap, heatmapTable, minuteChart, rankTable, regionName, statTiles, trendChart, trendTable, worldMap,
|
|
14
|
+
} from './charts.js';
|
|
15
|
+
import { el, replaceChildren, uniqueId } from './dom.js';
|
|
16
|
+
import {
|
|
17
|
+
formatCompact, formatDateTime, formatDecimal, formatDuration, formatHeadline, formatNumber, formatPercent, formatTime, periodStart,
|
|
18
|
+
} from './format.js';
|
|
19
|
+
import { icon } from './icons.js';
|
|
20
|
+
import { currentLocale, t, tOr } from './i18n.js';
|
|
21
|
+
import { createListbox } from './listbox.js';
|
|
22
|
+
import { createSegmented } from './table.js';
|
|
23
|
+
import { ALL_APPS, RANGE, REALTIME_REFRESH_MS, STORAGE_KEY, VIEW_STATUS } from './constants.js';
|
|
24
|
+
|
|
25
|
+
const MINUTE_MS = 60 * 1000;
|
|
26
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
27
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
28
|
+
/** Mirrors ADMIN_RANGE_DAYS on the server. */
|
|
29
|
+
const RANGE_DAYS = { [RANGE.DAY]: 1, [RANGE.WEEK]: 7, [RANGE.MONTH]: 30, [RANGE.QUARTER]: 90, [RANGE.YEAR]: 365 };
|
|
30
|
+
const DEFAULT_RANGE = RANGE.MONTH;
|
|
31
|
+
const DEFAULT_METRIC = 'visitors';
|
|
32
|
+
|
|
33
|
+
const present = (value) => value !== null && value !== undefined && !Number.isNaN(value);
|
|
34
|
+
|
|
35
|
+
// ---- Remembered choices (this browser only) -------------------------------------
|
|
36
|
+
|
|
37
|
+
function readStored(key, fallback) {
|
|
38
|
+
try {
|
|
39
|
+
return localStorage.getItem(key) ?? fallback;
|
|
40
|
+
} catch {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function store(key, value) {
|
|
46
|
+
try {
|
|
47
|
+
localStorage.setItem(key, value);
|
|
48
|
+
} catch {
|
|
49
|
+
// Storage blocked: the choice simply is not remembered.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readCardTabs() {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(readStored(STORAGE_KEY.OVERVIEW_CARDS, '{}'));
|
|
56
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
57
|
+
} catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- Periods ----------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** The label of the bucket a moment falls in, as the server writes it (UTC). */
|
|
65
|
+
export function bucketLabel(date, bucket) {
|
|
66
|
+
const iso = date.toISOString();
|
|
67
|
+
if (bucket === 'hour') return `${iso.slice(0, 10)} ${iso.slice(11, 13)}:00`;
|
|
68
|
+
if (bucket === 'month') return `${iso.slice(0, 8)}01`;
|
|
69
|
+
if (bucket === 'week') {
|
|
70
|
+
const day = new Date(`${iso.slice(0, 10)}T00:00:00Z`);
|
|
71
|
+
return new Date(day.getTime() - ((day.getUTCDay() + 6) % 7) * DAY_MS).toISOString().slice(0, 10);
|
|
72
|
+
}
|
|
73
|
+
return iso.slice(0, 10);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The period after `period` by one bucket, labelled as the server labels it. */
|
|
77
|
+
export function nextPeriod(period, bucket) {
|
|
78
|
+
const date = periodStart(period, bucket);
|
|
79
|
+
if (bucket === 'hour') return bucketLabel(new Date(date.getTime() + HOUR_MS), bucket);
|
|
80
|
+
if (bucket === 'month') date.setUTCMonth(date.getUTCMonth() + 1);
|
|
81
|
+
else date.setTime(date.getTime() + (bucket === 'week' ? 7 : 1) * DAY_MS);
|
|
82
|
+
return date.toISOString().slice(0, 10);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A period with no views: counts are zero, averages unknown. */
|
|
86
|
+
const emptyPeriod = (period) => ({
|
|
87
|
+
period, views: 0, pageviews: 0, uniqueViews: 0, visitors: 0, visits: 0,
|
|
88
|
+
bounceRate: null, avgVisitMs: null, pagesPerVisit: null, avgEngagedMs: null, avgScroll: null,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Every period from `from` (or the first with views) to the last, with the
|
|
93
|
+
* quiet ones as zero, so a quiet day reads as a dip rather than vanishing.
|
|
94
|
+
* @param {object[]} trend as the server sends it, ordered
|
|
95
|
+
* @param {string} bucket
|
|
96
|
+
* @param {Date|null} [from] the start of a bounded range
|
|
97
|
+
* @param {Date} [to]
|
|
98
|
+
*/
|
|
99
|
+
export function fillGaps(trend, bucket, from = null, to = new Date()) {
|
|
100
|
+
const first = from ? bucketLabel(from, bucket) : trend[0]?.period;
|
|
101
|
+
const last = from ? bucketLabel(to, bucket) : trend[trend.length - 1]?.period;
|
|
102
|
+
if (!first || !last) return trend;
|
|
103
|
+
const byPeriod = new Map(trend.map((point) => [point.period, point]));
|
|
104
|
+
const filled = [];
|
|
105
|
+
for (let period = first; period <= last; period = nextPeriod(period, bucket)) {
|
|
106
|
+
filled.push(byPeriod.get(period) || emptyPeriod(period));
|
|
107
|
+
}
|
|
108
|
+
// Anything outside the window (a clock a moment apart) is kept, not lost.
|
|
109
|
+
for (const point of trend) if (point.period < first || point.period > last) filled.push(point);
|
|
110
|
+
return filled.sort((a, b) => a.period.localeCompare(b.period));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- Metrics ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
const read = (key) => (source) => (source ? source[key] ?? null : null);
|
|
116
|
+
const COUNT = { format: formatNumber, tile: formatHeadline, tick: formatCompact, kind: 'count', better: 'up' };
|
|
117
|
+
const DURATION = { format: formatDuration, tile: formatDuration, tick: formatDuration, kind: 'duration', better: 'up', area: false };
|
|
118
|
+
|
|
119
|
+
/** The headline numbers, in tile order. `read` takes totals or a trend point. */
|
|
120
|
+
const METRICS = [
|
|
121
|
+
{ key: 'visitors', label: () => t('overview.metrics.visitors'), help: () => t('overview.help.visitors'), read: read('visitors'), ...COUNT },
|
|
122
|
+
{ key: 'visits', label: () => t('overview.metrics.visits'), help: () => t('overview.help.visits'), read: read('visits'), ...COUNT },
|
|
123
|
+
{ key: 'pageviews', label: () => t('overview.metrics.pageviews'), help: () => t('overview.help.pageviews'), read: read('pageviews'), ...COUNT },
|
|
124
|
+
{ key: 'views', label: () => t('overview.metrics.views'), help: () => t('overview.help.views'), read: read('views'), ...COUNT },
|
|
125
|
+
{
|
|
126
|
+
key: 'bounceRate', label: () => t('overview.metrics.bounceRate'), help: () => t('overview.help.bounceRate'),
|
|
127
|
+
read: read('bounceRate'), format: formatPercent, tile: formatPercent, tick: formatPercent,
|
|
128
|
+
kind: 'rate', better: 'down', integer: false, area: false,
|
|
129
|
+
},
|
|
130
|
+
{ key: 'avgVisitMs', label: () => t('overview.metrics.avgVisitMs'), help: () => t('overview.help.avgVisitMs'), read: read('avgVisitMs'), ...DURATION },
|
|
131
|
+
{
|
|
132
|
+
key: 'pagesPerVisit', label: () => t('overview.metrics.pagesPerVisit'), help: () => t('overview.help.pagesPerVisit'),
|
|
133
|
+
read: read('pagesPerVisit'), format: (value) => formatDecimal(value, 2), tile: (value) => formatDecimal(value, 1),
|
|
134
|
+
tick: (value) => formatDecimal(value, 1), kind: 'ratio', better: 'up', integer: false, area: false,
|
|
135
|
+
},
|
|
136
|
+
{ key: 'avgEngagedMs', label: () => t('overview.metrics.avgEngagedMs'), help: () => t('overview.help.avgEngagedMs'), read: read('avgEngagedMs'), ...DURATION },
|
|
137
|
+
{
|
|
138
|
+
key: 'avgScroll', label: () => t('overview.metrics.avgScroll'), help: () => t('overview.help.avgScroll'),
|
|
139
|
+
read: (source) => (present(source?.avgScroll) ? source.avgScroll / 100 : null),
|
|
140
|
+
format: formatPercent, tile: formatPercent, tick: formatPercent, kind: 'rate', better: 'up', integer: false, area: false,
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
const metricByKey = (key) => METRICS.find((metric) => metric.key === key) ?? METRICS[0];
|
|
144
|
+
|
|
145
|
+
function signed(value, options) {
|
|
146
|
+
return new Intl.NumberFormat(currentLocale(), { maximumFractionDigits: 1, signDisplay: 'exceptZero', ...options }).format(value);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The change against the period before: relative for counts and averages,
|
|
151
|
+
* in percentage points for rates. Toned good or bad by which way is better.
|
|
152
|
+
*/
|
|
153
|
+
export function delta(metric, totals, previous, range) {
|
|
154
|
+
if (!previous || !RANGE_DAYS[range]) return null;
|
|
155
|
+
const now = metric.read(totals);
|
|
156
|
+
const before = metric.read(previous);
|
|
157
|
+
if (!present(now) || !present(before)) return null;
|
|
158
|
+
const period = t(`overview.vsPrevious.${range}`);
|
|
159
|
+
let change;
|
|
160
|
+
let text;
|
|
161
|
+
if (metric.kind === 'rate') {
|
|
162
|
+
change = now - before;
|
|
163
|
+
text = t('overview.points', { value: signed(change * 100) });
|
|
164
|
+
} else if (before === 0) {
|
|
165
|
+
if (now === 0) return { text: signed(0, { style: 'percent' }), direction: 'flat', tone: 'neutral', period };
|
|
166
|
+
return { text: t('overview.deltaNew'), direction: 'up', tone: metric.better === 'up' ? 'good' : 'bad', period };
|
|
167
|
+
} else {
|
|
168
|
+
change = (now - before) / before;
|
|
169
|
+
text = signed(change, { style: 'percent' });
|
|
170
|
+
}
|
|
171
|
+
const direction = Math.abs(change) < 0.005 ? 'flat' : change > 0 ? 'up' : 'down';
|
|
172
|
+
const tone = direction === 'flat' ? 'neutral' : (direction === 'up') === (metric.better === 'up') ? 'good' : 'bad';
|
|
173
|
+
return { text, direction, tone, period };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- Values in breakdowns -------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
function languageName(code) {
|
|
179
|
+
try {
|
|
180
|
+
const name = new Intl.DisplayNames([currentLocale()], { type: 'language' }).of(code);
|
|
181
|
+
return name && name !== code ? `${name} (${code})` : code;
|
|
182
|
+
} catch {
|
|
183
|
+
return code;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** "Bavaria, DE" as "Bavaria, Germany". */
|
|
188
|
+
function placeName(value) {
|
|
189
|
+
const cut = value.lastIndexOf(', ');
|
|
190
|
+
if (cut < 0) return value;
|
|
191
|
+
const code = value.slice(cut + 2);
|
|
192
|
+
return code === '?' ? value.slice(0, cut) : `${value.slice(0, cut)}, ${regionName(code)}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** What a missing value means in each breakdown. */
|
|
196
|
+
function noneLabel(dim) {
|
|
197
|
+
if (dim === 'referrer' || dim === 'referrerUrl') return t('overview.noReferrer');
|
|
198
|
+
if (dim.startsWith('utm')) return t('overview.notTagged');
|
|
199
|
+
if (dim === 'title') return t('overview.noTitle');
|
|
200
|
+
return t('overview.unknown');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A breakdown value as the admin reads it. */
|
|
204
|
+
export function valueLabel(dim, value) {
|
|
205
|
+
if (value === null || value === undefined || value === '') return noneLabel(dim);
|
|
206
|
+
switch (dim) {
|
|
207
|
+
case 'source': return tOr(`sources.${value}`, value);
|
|
208
|
+
case 'country': return regionName(value);
|
|
209
|
+
case 'region':
|
|
210
|
+
case 'city': return placeName(value);
|
|
211
|
+
case 'language': return languageName(value);
|
|
212
|
+
case 'deviceSize': return tOr(`deviceSizes.${value}`, value);
|
|
213
|
+
case 'deviceType': return tOr(`deviceTypes.${value}`, value);
|
|
214
|
+
case 'eventType': return tOr(`eventTypes.${value}`, value);
|
|
215
|
+
default: return value;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const dimLabel = (dim) => t(`overview.dims.${dim}`);
|
|
220
|
+
|
|
221
|
+
// ---- Panel ----------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* @param {{ meta: object, context: { apps: () => object[], appId: () => string, setAppId: (id: string) => void,
|
|
225
|
+
* reportError: (error: unknown) => void, openViews: (filters: object) => void } }} deps
|
|
226
|
+
*/
|
|
227
|
+
export function createOverviewPanel({ meta, context }) {
|
|
228
|
+
const rememberedRange = readStored(STORAGE_KEY.OVERVIEW_RANGE, DEFAULT_RANGE);
|
|
229
|
+
const state = {
|
|
230
|
+
range: meta.ranges.includes(rememberedRange) ? rememberedRange : DEFAULT_RANGE,
|
|
231
|
+
eventType: '',
|
|
232
|
+
eventTypes: [],
|
|
233
|
+
where: {},
|
|
234
|
+
metric: metricByKey(readStored(STORAGE_KEY.OVERVIEW_METRIC, DEFAULT_METRIC)).key,
|
|
235
|
+
trendView: 'chart',
|
|
236
|
+
data: null,
|
|
237
|
+
seq: 0,
|
|
238
|
+
visible: false,
|
|
239
|
+
stale: true,
|
|
240
|
+
cardTabs: readCardTabs(),
|
|
241
|
+
};
|
|
242
|
+
const realtime = { seq: 0, timer: null, data: null, failed: false, updatedAt: null };
|
|
243
|
+
|
|
244
|
+
const contentId = uniqueId('overview-content');
|
|
245
|
+
const appTabs = createAppTabs({ context, controls: contentId });
|
|
246
|
+
|
|
247
|
+
// ---- Filters --------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
const rangeFilter = createSegmented({
|
|
250
|
+
label: t('toolbar.range'),
|
|
251
|
+
value: state.range,
|
|
252
|
+
options: meta.ranges.map((value) => ({ value, label: t(`ranges.${value}`) })),
|
|
253
|
+
onChange: (value) => {
|
|
254
|
+
state.range = value;
|
|
255
|
+
store(STORAGE_KEY.OVERVIEW_RANGE, value);
|
|
256
|
+
load();
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const eventTypeOptions = () => [
|
|
261
|
+
{ value: '', label: t('toolbar.allEventTypes') },
|
|
262
|
+
...state.eventTypes.map((type) => ({ value: type, label: tOr(`eventTypes.${type}`, type) })),
|
|
263
|
+
];
|
|
264
|
+
const eventTypePicker = createListbox({
|
|
265
|
+
label: t('toolbar.eventType'),
|
|
266
|
+
options: eventTypeOptions(),
|
|
267
|
+
value: '',
|
|
268
|
+
className: 'event-type-picker',
|
|
269
|
+
onChange: (value) => {
|
|
270
|
+
state.eventType = value;
|
|
271
|
+
load();
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const chips = el('div', { className: 'filter-chips', attrs: { 'aria-live': 'polite' } });
|
|
276
|
+
const showViews = el('button', {
|
|
277
|
+
className: 'btn btn-secondary btn-small',
|
|
278
|
+
attrs: { type: 'button' },
|
|
279
|
+
on: { click: () => context.openViews({ range: state.range, eventType: state.eventType, where: { ...state.where } }) },
|
|
280
|
+
}, [el('span', { text: t('overview.showViews') }), icon('arrowRight')]);
|
|
281
|
+
|
|
282
|
+
function renderChips() {
|
|
283
|
+
const entries = Object.entries(state.where);
|
|
284
|
+
replaceChildren(chips, [
|
|
285
|
+
...entries.map(([dim, value]) => {
|
|
286
|
+
const label = `${dimLabel(dim)}: ${valueLabel(dim, value)}`;
|
|
287
|
+
return el('button', {
|
|
288
|
+
className: 'chip',
|
|
289
|
+
attrs: { type: 'button', 'aria-label': t('overview.removeFilter', { filter: label }), title: t('overview.removeFilter', { filter: label }) },
|
|
290
|
+
dataset: { dim },
|
|
291
|
+
on: { click: () => {
|
|
292
|
+
delete state.where[dim];
|
|
293
|
+
load();
|
|
294
|
+
} },
|
|
295
|
+
}, [icon('filter'), el('span', { className: 'chip-text', text: label }), icon('close')]);
|
|
296
|
+
}),
|
|
297
|
+
entries.length > 1 ? el('button', {
|
|
298
|
+
className: 'btn btn-ghost btn-small',
|
|
299
|
+
text: t('overview.clearFilters'),
|
|
300
|
+
attrs: { type: 'button' },
|
|
301
|
+
on: { click: () => {
|
|
302
|
+
state.where = {};
|
|
303
|
+
load();
|
|
304
|
+
} },
|
|
305
|
+
}) : null,
|
|
306
|
+
]);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Narrow everything to one breakdown value. */
|
|
310
|
+
function selectValue(dim, value) {
|
|
311
|
+
if (dim === 'app') {
|
|
312
|
+
if (value) context.setAppId(value);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (dim === 'eventType' && value) {
|
|
316
|
+
state.eventType = value;
|
|
317
|
+
eventTypePicker.setValue(value);
|
|
318
|
+
} else {
|
|
319
|
+
state.where = { ...state.where, [dim]: value };
|
|
320
|
+
}
|
|
321
|
+
load();
|
|
322
|
+
filtersRow.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const filtersRow = el('div', { className: 'toolbar overview-filters' }, [
|
|
326
|
+
el('div', { className: 'toolbar-group' }, [rangeFilter.element, eventTypePicker.element, chips]),
|
|
327
|
+
el('div', { className: 'toolbar-group' }, [showViews]),
|
|
328
|
+
]);
|
|
329
|
+
|
|
330
|
+
// ---- Headline ---------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
const tilesHost = el('div', { className: 'overview-tiles' });
|
|
333
|
+
const span = el('p', { className: 'overview-span' });
|
|
334
|
+
const help = el('details', { className: 'metric-help' }, [
|
|
335
|
+
el('summary', { text: t('overview.helpTitle') }),
|
|
336
|
+
el('dl', {}, METRICS.flatMap((metric) => [el('dt', { text: metric.label() }), el('dd', { text: metric.help() })])),
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
// ---- Cards ------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* A titled card whose body is one of several views, chosen in its header.
|
|
343
|
+
* @param {{ id: string, title: string, tabs: Array<{ key: string, label: string, render: () => Node }>,
|
|
344
|
+
* footer?: () => Node|null }} options
|
|
345
|
+
*/
|
|
346
|
+
function card({ id, title, tabs, footer }) {
|
|
347
|
+
const titleId = uniqueId('card-title');
|
|
348
|
+
const body = el('div', { className: 'card-body' });
|
|
349
|
+
const foot = el('div', { className: 'card-footer' });
|
|
350
|
+
let current = tabs.some((tab) => tab.key === state.cardTabs[id]) ? state.cardTabs[id] : tabs[0].key;
|
|
351
|
+
const picker = tabs.length > 1 ? createSegmented({
|
|
352
|
+
label: t('overview.cardView', { card: title }),
|
|
353
|
+
value: current,
|
|
354
|
+
options: tabs.map((tab) => ({ value: tab.key, label: tab.label })),
|
|
355
|
+
onChange: (key) => {
|
|
356
|
+
current = key;
|
|
357
|
+
state.cardTabs = { ...state.cardTabs, [id]: key };
|
|
358
|
+
store(STORAGE_KEY.OVERVIEW_CARDS, JSON.stringify(state.cardTabs));
|
|
359
|
+
render();
|
|
360
|
+
},
|
|
361
|
+
}) : null;
|
|
362
|
+
const element = el('section', { className: 'chart-card', attrs: { 'aria-labelledby': titleId }, dataset: { card: id } }, [
|
|
363
|
+
el('header', { className: 'chart-card-header' }, [
|
|
364
|
+
el('h3', { className: 'chart-title', text: title, attrs: { id: titleId } }),
|
|
365
|
+
picker?.element,
|
|
366
|
+
]),
|
|
367
|
+
body,
|
|
368
|
+
foot,
|
|
369
|
+
]);
|
|
370
|
+
function render() {
|
|
371
|
+
if (!state.data) return;
|
|
372
|
+
replaceChildren(body, [tabs.find((tab) => tab.key === current).render()]);
|
|
373
|
+
const note = footer?.();
|
|
374
|
+
foot.hidden = !note;
|
|
375
|
+
replaceChildren(foot, note ? [note] : []);
|
|
376
|
+
}
|
|
377
|
+
return { element, render };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const columns = {
|
|
381
|
+
visitors: { label: t('overview.columns.visitors'), value: (row) => row.visitors, format: formatNumber },
|
|
382
|
+
views: { label: t('overview.columns.views'), value: (row) => row.views, format: formatNumber },
|
|
383
|
+
share: {
|
|
384
|
+
label: t('overview.columns.share'),
|
|
385
|
+
value: (row) => row.share,
|
|
386
|
+
format: formatPercent,
|
|
387
|
+
className: 'rank-share',
|
|
388
|
+
},
|
|
389
|
+
pageviews: { label: t('overview.columns.pageviews'), value: (row) => row.views, format: formatNumber },
|
|
390
|
+
visits: { label: t('overview.columns.visits'), value: (row) => row.visits, format: formatNumber },
|
|
391
|
+
bounceRate: { label: t('overview.columns.bounceRate'), value: (row) => row.bounceRate, format: formatPercent },
|
|
392
|
+
engaged: { label: t('overview.columns.timeOnPage'), value: (row) => row.avgEngagedMs, format: formatDuration, className: 'rank-share' },
|
|
393
|
+
scroll: {
|
|
394
|
+
label: t('overview.columns.scroll'),
|
|
395
|
+
value: (row) => (present(row.avgScroll) ? row.avgScroll / 100 : null),
|
|
396
|
+
format: formatPercent,
|
|
397
|
+
className: 'rank-share',
|
|
398
|
+
},
|
|
399
|
+
steps: { label: t('overview.columns.steps'), value: (row) => row.steps, format: formatNumber },
|
|
400
|
+
count: { label: t('overview.columns.count'), value: (row) => row.count, format: formatNumber },
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const byViews = (entries, key = 'views') => {
|
|
404
|
+
const max = Math.max(0, ...entries.map((entry) => entry[key]));
|
|
405
|
+
return (entry) => (max ? entry[key] / max : 0);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* A breakdown's rows, each a filter, with the rest folded into "Everything
|
|
410
|
+
* else". How visits arrived counts page views; the rest count every view.
|
|
411
|
+
*/
|
|
412
|
+
function breakdown(dim, { labelHeader = dimLabel(dim), filter = true } = {}) {
|
|
413
|
+
const entries = state.data.breakdowns[dim] ?? [];
|
|
414
|
+
const base = state.data.breakdownTotals?.[dim] ?? state.data.totals.views;
|
|
415
|
+
const share = (views) => (base ? views / base : null);
|
|
416
|
+
const bar = byViews(entries);
|
|
417
|
+
const rows = entries.map((entry) => {
|
|
418
|
+
const label = valueLabel(dim, entry.value);
|
|
419
|
+
const active = Object.hasOwn(state.where, dim) && state.where[dim] === entry.value;
|
|
420
|
+
return {
|
|
421
|
+
...entry,
|
|
422
|
+
label,
|
|
423
|
+
share: share(entry.views),
|
|
424
|
+
bar: bar(entry),
|
|
425
|
+
onSelect: filter && !active ? () => selectValue(dim, entry.value) : undefined,
|
|
426
|
+
selectLabel: t('overview.filterBy', { dimension: dimLabel(dim), value: label }),
|
|
427
|
+
};
|
|
428
|
+
});
|
|
429
|
+
const shown = entries.reduce((sum, entry) => sum + entry.views, 0);
|
|
430
|
+
const rest = base - shown;
|
|
431
|
+
if (entries.length && rest > 0) {
|
|
432
|
+
rows.push({
|
|
433
|
+
label: t('overview.other'), views: rest, visitors: null, share: share(rest),
|
|
434
|
+
bar: Math.min(1, bar({ views: rest })), muted: true,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
const counted = meta.acquisitionDimensions.includes(dim) ? columns.pageviews : columns.views;
|
|
438
|
+
return rankTable({
|
|
439
|
+
labelHeader, columns: [columns.visitors, counted, columns.share], rows, empty: t('overview.empty'),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const appDetail = (appId) => (context.appId() === ALL_APPS ? appId : undefined);
|
|
444
|
+
const pageRow = (entry, key = 'views') => ({
|
|
445
|
+
...entry,
|
|
446
|
+
label: valueLabel('page', entry.page),
|
|
447
|
+
detail: appDetail(entry.appId),
|
|
448
|
+
onSelect: () => selectValue('page', entry.page),
|
|
449
|
+
selectLabel: t('overview.filterBy', { dimension: dimLabel('page'), value: valueLabel('page', entry.page) }),
|
|
450
|
+
key,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
function pagesTable() {
|
|
454
|
+
const bar = byViews(state.data.pages);
|
|
455
|
+
return rankTable({
|
|
456
|
+
labelHeader: dimLabel('page'),
|
|
457
|
+
columns: [columns.visitors, columns.views, columns.engaged, columns.scroll],
|
|
458
|
+
rows: state.data.pages.map((entry) => ({ ...pageRow(entry), bar: bar(entry) })),
|
|
459
|
+
empty: t('overview.empty'),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function landingTable(entries, withBounce) {
|
|
464
|
+
const bar = byViews(entries, 'visits');
|
|
465
|
+
return rankTable({
|
|
466
|
+
labelHeader: dimLabel('page'),
|
|
467
|
+
columns: withBounce ? [columns.visits, columns.bounceRate] : [columns.visits],
|
|
468
|
+
rows: entries.map((entry) => ({ ...pageRow(entry, 'visits'), bar: bar(entry) })),
|
|
469
|
+
empty: t('overview.noVisits'),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function flowTable() {
|
|
474
|
+
const bar = byViews(state.data.transitions, 'steps');
|
|
475
|
+
return rankTable({
|
|
476
|
+
labelHeader: t('overview.flowHeader'),
|
|
477
|
+
columns: [columns.steps],
|
|
478
|
+
rows: state.data.transitions.map((entry) => ({
|
|
479
|
+
...entry,
|
|
480
|
+
label: t('overview.flowStep', { from: valueLabel('page', entry.from), to: valueLabel('page', entry.to) }),
|
|
481
|
+
detail: appDetail(entry.appId),
|
|
482
|
+
bar: bar(entry),
|
|
483
|
+
})),
|
|
484
|
+
empty: t('overview.noFlow'),
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function eventPropertiesTable() {
|
|
489
|
+
const bar = byViews(state.data.eventProperties, 'count');
|
|
490
|
+
return rankTable({
|
|
491
|
+
labelHeader: t('overview.propertyHeader'),
|
|
492
|
+
columns: [columns.count],
|
|
493
|
+
rows: state.data.eventProperties.map((entry) => ({
|
|
494
|
+
...entry,
|
|
495
|
+
label: t('overview.property', { key: entry.key, value: entry.value }),
|
|
496
|
+
detail: valueLabel('eventType', entry.eventType),
|
|
497
|
+
bar: bar(entry),
|
|
498
|
+
})),
|
|
499
|
+
empty: t('overview.noProperties'),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Ordered buckets, every one shown (a zero is information here). */
|
|
504
|
+
function distribution(entries, bounds, keyOf, labelFor, header) {
|
|
505
|
+
const counts = new Map(entries.map((entry) => [keyOf(entry), entry.views]));
|
|
506
|
+
const total = entries.reduce((sum, entry) => sum + entry.views, 0);
|
|
507
|
+
const max = Math.max(0, ...counts.values());
|
|
508
|
+
if (total === 0) return el('p', { className: 'chart-empty', text: t('overview.noEngagement') });
|
|
509
|
+
return rankTable({
|
|
510
|
+
labelHeader: header,
|
|
511
|
+
columns: [
|
|
512
|
+
{ label: t('overview.columns.pageviews'), value: (row) => row.views, format: formatNumber },
|
|
513
|
+
{ label: t('overview.columns.share'), value: (row) => row.views / total, format: formatPercent },
|
|
514
|
+
],
|
|
515
|
+
rows: bounds.map((bound, index) => {
|
|
516
|
+
const views = counts.get(bound) ?? 0;
|
|
517
|
+
return { label: labelFor(bound, bounds[index + 1]), views, bar: max ? views / max : 0 };
|
|
518
|
+
}),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const timeOnPage = () => distribution(
|
|
523
|
+
state.data.timeOnPage, [0, 10, 30, 60, 180, 600], (entry) => entry.fromSeconds,
|
|
524
|
+
(from, to) => {
|
|
525
|
+
if (to === undefined) return t('overview.orMore', { from: formatDuration(from * 1000) });
|
|
526
|
+
if (from === 0) return t('overview.under', { to: formatDuration(to * 1000) });
|
|
527
|
+
return t('overview.between', { from: formatDuration(from * 1000), to: formatDuration(to * 1000) });
|
|
528
|
+
},
|
|
529
|
+
t('overview.dims.timeOnPage'),
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
const scrollDepth = () => distribution(
|
|
533
|
+
state.data.scrollDepth, [0, 25, 50, 75, 100], (entry) => entry.from,
|
|
534
|
+
(from, to) => (to === undefined ? t('overview.scrollAll') : t('overview.scrollBetween', { from, to: to - 1 })),
|
|
535
|
+
t('overview.dims.scrollDepth'),
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
const regionTable = (dim) => () => (meta.hasCityData || (state.data.breakdowns[dim] ?? []).some((entry) => entry.value !== null)
|
|
539
|
+
? breakdown(dim)
|
|
540
|
+
: el('p', { className: 'chart-empty', text: t('overview.noCityData') }));
|
|
541
|
+
|
|
542
|
+
const attribution = () => el('p', { className: 'card-note' }, (meta.attributions ?? []).flatMap((credit, index) => [
|
|
543
|
+
index ? ' · ' : null,
|
|
544
|
+
credit.url
|
|
545
|
+
? el('a', { text: credit.text, attrs: { href: credit.url, target: '_blank', rel: 'noopener noreferrer' } })
|
|
546
|
+
: credit.text,
|
|
547
|
+
]));
|
|
548
|
+
|
|
549
|
+
let heatmapView = 'chart';
|
|
550
|
+
const cards = {
|
|
551
|
+
apps: card({ id: 'apps', title: t('overview.cards.apps'), tabs: [
|
|
552
|
+
{ key: 'apps', label: dimLabel('app'), render: () => breakdown('app') },
|
|
553
|
+
] }),
|
|
554
|
+
sources: card({ id: 'sources', title: t('overview.cards.sources'), tabs: [
|
|
555
|
+
{ key: 'source', label: t('overview.tabs.channels'), render: () => breakdown('source') },
|
|
556
|
+
{ key: 'referrer', label: t('overview.tabs.referrers'), render: () => breakdown('referrer') },
|
|
557
|
+
{ key: 'referrerUrl', label: t('overview.tabs.referringPages'), render: () => breakdown('referrerUrl') },
|
|
558
|
+
] }),
|
|
559
|
+
pages: card({ id: 'pages', title: t('overview.cards.pages'), tabs: [
|
|
560
|
+
{ key: 'top', label: t('overview.tabs.topPages'), render: pagesTable },
|
|
561
|
+
{ key: 'entry', label: t('overview.tabs.entryPages'), render: () => landingTable(state.data.entryPages, true) },
|
|
562
|
+
{ key: 'exit', label: t('overview.tabs.exitPages'), render: () => landingTable(state.data.exitPages, false) },
|
|
563
|
+
{ key: 'title', label: t('overview.tabs.titles'), render: () => breakdown('title') },
|
|
564
|
+
{ key: 'hostname', label: t('overview.tabs.sites'), render: () => breakdown('hostname') },
|
|
565
|
+
] }),
|
|
566
|
+
locations: card({ id: 'locations', title: t('overview.cards.locations'), footer: attribution, tabs: [
|
|
567
|
+
{ key: 'map', label: t('overview.tabs.map'), render: () => worldMap({
|
|
568
|
+
countries: state.data.countries,
|
|
569
|
+
onSelect: (code) => selectValue('country', code),
|
|
570
|
+
}) },
|
|
571
|
+
{ key: 'country', label: t('overview.tabs.countries'), render: () => breakdown('country') },
|
|
572
|
+
{ key: 'region', label: t('overview.tabs.regions'), render: regionTable('region') },
|
|
573
|
+
{ key: 'city', label: t('overview.tabs.cities'), render: regionTable('city') },
|
|
574
|
+
{ key: 'language', label: t('overview.tabs.languages'), render: () => breakdown('language') },
|
|
575
|
+
] }),
|
|
576
|
+
devices: card({ id: 'devices', title: t('overview.cards.devices'), tabs: [
|
|
577
|
+
{ key: 'deviceSize', label: t('overview.tabs.screenSizes'), render: () => breakdown('deviceSize') },
|
|
578
|
+
{ key: 'deviceType', label: t('overview.tabs.deviceTypes'), render: () => breakdown('deviceType') },
|
|
579
|
+
] }),
|
|
580
|
+
browsers: card({ id: 'browsers', title: t('overview.cards.browsers'), tabs: [
|
|
581
|
+
{ key: 'browser', label: t('overview.tabs.browsers'), render: () => breakdown('browser') },
|
|
582
|
+
{ key: 'browserVersion', label: t('overview.tabs.versions'), render: () => breakdown('browserVersion') },
|
|
583
|
+
] }),
|
|
584
|
+
systems: card({ id: 'systems', title: t('overview.cards.systems'), tabs: [
|
|
585
|
+
{ key: 'os', label: t('overview.tabs.systems'), render: () => breakdown('os') },
|
|
586
|
+
{ key: 'osVersion', label: t('overview.tabs.versions'), render: () => breakdown('osVersion') },
|
|
587
|
+
] }),
|
|
588
|
+
campaigns: card({ id: 'campaigns', title: t('overview.cards.campaigns'), tabs: [
|
|
589
|
+
{ key: 'utmSource', label: t('overview.tabs.utmSource'), render: () => breakdown('utmSource') },
|
|
590
|
+
{ key: 'utmMedium', label: t('overview.tabs.utmMedium'), render: () => breakdown('utmMedium') },
|
|
591
|
+
{ key: 'utmCampaign', label: t('overview.tabs.utmCampaign'), render: () => breakdown('utmCampaign') },
|
|
592
|
+
{ key: 'utmTerm', label: t('overview.tabs.utmTerm'), render: () => breakdown('utmTerm') },
|
|
593
|
+
{ key: 'utmContent', label: t('overview.tabs.utmContent'), render: () => breakdown('utmContent') },
|
|
594
|
+
] }),
|
|
595
|
+
events: card({ id: 'events', title: t('overview.cards.events'), tabs: [
|
|
596
|
+
{ key: 'eventType', label: t('overview.tabs.eventTypes'), render: () => breakdown('eventType') },
|
|
597
|
+
{ key: 'properties', label: t('overview.tabs.properties'), render: eventPropertiesTable },
|
|
598
|
+
] }),
|
|
599
|
+
engagement: card({ id: 'engagement', title: t('overview.cards.engagement'), tabs: [
|
|
600
|
+
{ key: 'time', label: t('overview.tabs.timeOnPage'), render: timeOnPage },
|
|
601
|
+
{ key: 'scroll', label: t('overview.tabs.scrollDepth'), render: scrollDepth },
|
|
602
|
+
] }),
|
|
603
|
+
when: card({
|
|
604
|
+
id: 'when',
|
|
605
|
+
title: t('overview.cards.when', { zone: Intl.DateTimeFormat().resolvedOptions().timeZone }),
|
|
606
|
+
tabs: [
|
|
607
|
+
{ key: 'chart', label: t('charts.showChart'), render: () => {
|
|
608
|
+
heatmapView = 'chart';
|
|
609
|
+
return heatmap({ hours: state.data.hours });
|
|
610
|
+
} },
|
|
611
|
+
{ key: 'table', label: t('charts.showTable'), render: () => {
|
|
612
|
+
heatmapView = 'table';
|
|
613
|
+
return el('div', { className: 'chart-table-wrap' }, [heatmapTable({ hours: state.data.hours })]);
|
|
614
|
+
} },
|
|
615
|
+
],
|
|
616
|
+
footer: () => (heatmapView === 'chart' ? el('p', { className: 'card-note', text: t('overview.whenNote') }) : null),
|
|
617
|
+
}),
|
|
618
|
+
flow: card({ id: 'flow', title: t('overview.cards.flow'), tabs: [
|
|
619
|
+
{ key: 'flow', label: t('overview.cards.flow'), render: flowTable },
|
|
620
|
+
] }),
|
|
621
|
+
};
|
|
622
|
+
cards.locations.element.classList.add('card-wide');
|
|
623
|
+
cards.when.element.classList.add('card-wide');
|
|
624
|
+
|
|
625
|
+
// ---- Trend ------------------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
const trendTitleId = uniqueId('trend-title');
|
|
628
|
+
const trendTitle = el('h3', { className: 'chart-title', attrs: { id: trendTitleId } });
|
|
629
|
+
const trendBody = el('div', { className: 'card-body' });
|
|
630
|
+
const trendViewPicker = createSegmented({
|
|
631
|
+
label: t('overview.trendView'),
|
|
632
|
+
value: state.trendView,
|
|
633
|
+
options: [{ value: 'chart', label: t('charts.showChart') }, { value: 'table', label: t('charts.showTable') }],
|
|
634
|
+
onChange: (value) => {
|
|
635
|
+
state.trendView = value;
|
|
636
|
+
renderTrend();
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
const trendCard = el('section', { className: 'chart-card trend-card', attrs: { 'aria-labelledby': trendTitleId } }, [
|
|
640
|
+
el('header', { className: 'chart-card-header' }, [trendTitle, trendViewPicker.element]),
|
|
641
|
+
trendBody,
|
|
642
|
+
]);
|
|
643
|
+
|
|
644
|
+
/** The trend across the whole range, as the server bounded it, or across the data for all time. */
|
|
645
|
+
function trendPoints() {
|
|
646
|
+
const bounds = state.data.window;
|
|
647
|
+
return bounds
|
|
648
|
+
? fillGaps(state.data.trend, state.data.bucket, new Date(bounds.from), new Date(bounds.to))
|
|
649
|
+
: fillGaps(state.data.trend, state.data.bucket);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** "Now" as the server saw it, so the last period reads "so far" by the same clock. */
|
|
653
|
+
const serverNow = () => (state.data.window ? new Date(state.data.window.to) : new Date());
|
|
654
|
+
|
|
655
|
+
function renderTrend() {
|
|
656
|
+
const metric = metricByKey(state.metric);
|
|
657
|
+
trendTitle.textContent = t('overview.trendTitle', { metric: metric.label() });
|
|
658
|
+
const points = trendPoints();
|
|
659
|
+
if (state.trendView === 'table') {
|
|
660
|
+
replaceChildren(trendBody, [el('div', { className: 'chart-body chart-table-wrap' }, [trendTable({
|
|
661
|
+
points,
|
|
662
|
+
bucket: state.data.bucket,
|
|
663
|
+
metrics: METRICS.map((entry) => ({ label: entry.label(), value: entry.read, format: entry.format })),
|
|
664
|
+
})])]);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
// The last bucket is still filling up if it has not ended, so its dip
|
|
668
|
+
// is not a real decline; the tooltip says "so far".
|
|
669
|
+
const last = points[points.length - 1]?.period;
|
|
670
|
+
const lastInProgress = Boolean(last) && periodStart(nextPeriod(last, state.data.bucket), state.data.bucket) > serverNow();
|
|
671
|
+
replaceChildren(trendBody, [trendChart({
|
|
672
|
+
points,
|
|
673
|
+
bucket: state.data.bucket,
|
|
674
|
+
lastInProgress,
|
|
675
|
+
metric: {
|
|
676
|
+
label: metric.label(),
|
|
677
|
+
value: metric.read,
|
|
678
|
+
format: metric.format,
|
|
679
|
+
tick: metric.tick,
|
|
680
|
+
integer: metric.integer !== false,
|
|
681
|
+
area: metric.area !== false,
|
|
682
|
+
},
|
|
683
|
+
})]);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ---- Right now ----------------------------------------------------------------
|
|
687
|
+
|
|
688
|
+
const liveTitleId = uniqueId('live-title');
|
|
689
|
+
const liveBody = el('div', { className: 'card-body live-body' });
|
|
690
|
+
const liveUpdated = el('p', { className: 'card-note live-updated' });
|
|
691
|
+
const liveCard = el('section', { className: 'chart-card live-card', attrs: { 'aria-labelledby': liveTitleId } }, [
|
|
692
|
+
el('header', { className: 'chart-card-header' }, [
|
|
693
|
+
el('h3', { className: 'chart-title' }, [
|
|
694
|
+
el('span', { className: 'live-dot', attrs: { 'aria-hidden': 'true' } }),
|
|
695
|
+
el('span', { text: t('overview.live.title'), attrs: { id: liveTitleId } }),
|
|
696
|
+
]),
|
|
697
|
+
]),
|
|
698
|
+
liveBody,
|
|
699
|
+
liveUpdated,
|
|
700
|
+
]);
|
|
701
|
+
|
|
702
|
+
function renderLive() {
|
|
703
|
+
const data = realtime.data;
|
|
704
|
+
if (!data) return;
|
|
705
|
+
const bar = byViews(data.pages, 'visitors');
|
|
706
|
+
replaceChildren(liveBody, [
|
|
707
|
+
el('div', { className: 'live-headline' }, [
|
|
708
|
+
el('span', { className: 'live-count', text: formatNumber(data.visitors) }),
|
|
709
|
+
el('span', { className: 'live-caption', text: t('overview.live.visitors', { count: data.visitors, minutes: meta.realtimeVisitorMinutes }) }),
|
|
710
|
+
]),
|
|
711
|
+
minuteChart({ minutes: data.minutes, nowMinute: data.nowMinute }),
|
|
712
|
+
rankTable({
|
|
713
|
+
labelHeader: t('overview.live.pages'),
|
|
714
|
+
columns: [columns.visitors],
|
|
715
|
+
rows: data.pages.map((entry) => ({
|
|
716
|
+
...entry,
|
|
717
|
+
label: valueLabel('page', entry.page),
|
|
718
|
+
detail: appDetail(entry.appId),
|
|
719
|
+
bar: bar(entry),
|
|
720
|
+
})),
|
|
721
|
+
empty: t('overview.live.nobody'),
|
|
722
|
+
}),
|
|
723
|
+
]);
|
|
724
|
+
liveUpdated.textContent = t('overview.live.updated', {
|
|
725
|
+
time: formatTime(realtime.updatedAt, { seconds: true }),
|
|
726
|
+
seconds: REALTIME_REFRESH_MS / 1000,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function loadRealtime() {
|
|
731
|
+
const appId = context.appId();
|
|
732
|
+
if (!appId) return;
|
|
733
|
+
const mine = ++realtime.seq;
|
|
734
|
+
try {
|
|
735
|
+
const data = appId === ALL_APPS ? await api.realtimeAll() : await api.realtime(appId);
|
|
736
|
+
if (mine !== realtime.seq) return;
|
|
737
|
+
realtime.data = data;
|
|
738
|
+
realtime.updatedAt = new Date();
|
|
739
|
+
realtime.failed = false;
|
|
740
|
+
renderLive();
|
|
741
|
+
} catch (error) {
|
|
742
|
+
// Refreshed every few seconds: say it once, not on every attempt.
|
|
743
|
+
if (mine === realtime.seq && !realtime.failed) context.reportError(error);
|
|
744
|
+
if (mine === realtime.seq) realtime.failed = true;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function startRealtime() {
|
|
749
|
+
stopRealtime();
|
|
750
|
+
loadRealtime();
|
|
751
|
+
realtime.timer = setInterval(() => {
|
|
752
|
+
if (document.visibilityState === 'visible') loadRealtime();
|
|
753
|
+
}, REALTIME_REFRESH_MS);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function stopRealtime() {
|
|
757
|
+
clearInterval(realtime.timer);
|
|
758
|
+
realtime.timer = null;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ---- Layout -----------------------------------------------------------------
|
|
762
|
+
|
|
763
|
+
const grid = el('div', { className: 'overview-grid' });
|
|
764
|
+
const content = el('div', { className: 'overview-content', attrs: { id: contentId } }, [
|
|
765
|
+
filtersRow,
|
|
766
|
+
tilesHost,
|
|
767
|
+
span,
|
|
768
|
+
el('div', { className: 'overview-top' }, [trendCard, liveCard]),
|
|
769
|
+
grid,
|
|
770
|
+
help,
|
|
771
|
+
]);
|
|
772
|
+
|
|
773
|
+
const element = el('section', { className: 'panel overview', attrs: { 'aria-label': t('tabs.overview') } }, [
|
|
774
|
+
el('header', { className: 'panel-header' }, [
|
|
775
|
+
el('h2', { className: 'panel-title', text: t('tabs.overview') }),
|
|
776
|
+
el('p', { className: 'panel-intro', text: t('overview.intro') }),
|
|
777
|
+
]),
|
|
778
|
+
appTabs.element,
|
|
779
|
+
content,
|
|
780
|
+
]);
|
|
781
|
+
|
|
782
|
+
/** Cards in pairs, wide ones on their own row; a card left alone at the end spans the row. */
|
|
783
|
+
function layoutCards() {
|
|
784
|
+
const allApps = context.appId() === ALL_APPS;
|
|
785
|
+
const order = [
|
|
786
|
+
cards.sources, cards.pages, cards.locations,
|
|
787
|
+
cards.devices, cards.browsers, cards.systems, cards.campaigns, cards.events, cards.engagement,
|
|
788
|
+
cards.when, cards.flow, allApps ? cards.apps : null,
|
|
789
|
+
].filter(Boolean);
|
|
790
|
+
cards.flow.element.classList.toggle('card-wide', !allApps);
|
|
791
|
+
replaceChildren(grid, order.map((entry) => entry.element));
|
|
792
|
+
return order;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function render() {
|
|
796
|
+
const { data } = state;
|
|
797
|
+
if (!data) return;
|
|
798
|
+
const totals = data.totals ?? {};
|
|
799
|
+
statTilesRender(totals, data.previous);
|
|
800
|
+
|
|
801
|
+
span.textContent = totals.firstAt
|
|
802
|
+
? t('overview.span', {
|
|
803
|
+
from: formatDateTime(totals.firstAt),
|
|
804
|
+
to: formatDateTime(totals.lastAt),
|
|
805
|
+
countries: formatNumber(totals.countries),
|
|
806
|
+
count: totals.countries,
|
|
807
|
+
unique: formatPercent(totals.views ? totals.uniqueViews / totals.views : 0),
|
|
808
|
+
modified: formatNumber(totals.modified),
|
|
809
|
+
})
|
|
810
|
+
: t('overview.empty');
|
|
811
|
+
renderChips();
|
|
812
|
+
renderTrend();
|
|
813
|
+
for (const entry of layoutCards()) entry.render();
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function statTilesRender(totals, previous) {
|
|
817
|
+
const points = trendPoints();
|
|
818
|
+
replaceChildren(tilesHost, [statTiles(METRICS.map((metric) => {
|
|
819
|
+
const value = metric.read(totals);
|
|
820
|
+
return {
|
|
821
|
+
key: metric.key,
|
|
822
|
+
label: metric.label(),
|
|
823
|
+
value: present(value) ? metric.tile(value) : t('common.none'),
|
|
824
|
+
exact: present(value) ? metric.format(value) : undefined,
|
|
825
|
+
delta: delta(metric, totals, previous, state.range),
|
|
826
|
+
note: RANGE_DAYS[state.range] ? t('overview.noComparison') : t('overview.allTime'),
|
|
827
|
+
spark: points.map(metric.read),
|
|
828
|
+
};
|
|
829
|
+
}), {
|
|
830
|
+
selected: state.metric,
|
|
831
|
+
label: t('overview.tilesLabel'),
|
|
832
|
+
onSelect: (key) => {
|
|
833
|
+
state.metric = key;
|
|
834
|
+
store(STORAGE_KEY.OVERVIEW_METRIC, key);
|
|
835
|
+
statTilesRender(totals, previous);
|
|
836
|
+
renderTrend();
|
|
837
|
+
},
|
|
838
|
+
})]);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function syncEventTypes(types) {
|
|
842
|
+
const next = [...new Set([...(types ?? []), ...(state.eventType ? [state.eventType] : [])])].sort();
|
|
843
|
+
if (next.length === state.eventTypes.length && next.every((type, index) => type === state.eventTypes[index])) return;
|
|
844
|
+
state.eventTypes = next;
|
|
845
|
+
eventTypePicker.setOptions(eventTypeOptions(), state.eventType);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Fetch and draw the analysis. The previous render stays up, dimmed, until
|
|
850
|
+
* the new one arrives: no flash, no layout jump.
|
|
851
|
+
*/
|
|
852
|
+
async function load() {
|
|
853
|
+
const appId = context.appId();
|
|
854
|
+
if (!appId) return;
|
|
855
|
+
const mine = ++state.seq;
|
|
856
|
+
state.stale = false;
|
|
857
|
+
renderChips();
|
|
858
|
+
element.classList.add('loading');
|
|
859
|
+
try {
|
|
860
|
+
const query = { status: VIEW_STATUS.ACTIVE, range: state.range, eventType: state.eventType, where: state.where };
|
|
861
|
+
const result = appId === ALL_APPS ? await api.analyticsAll(query) : await api.analytics(appId, query);
|
|
862
|
+
if (mine !== state.seq) return;
|
|
863
|
+
state.data = result;
|
|
864
|
+
syncEventTypes(result.eventTypes);
|
|
865
|
+
render();
|
|
866
|
+
} catch (error) {
|
|
867
|
+
if (mine === state.seq) context.reportError(error);
|
|
868
|
+
} finally {
|
|
869
|
+
if (mine === state.seq) element.classList.remove('loading');
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
return {
|
|
874
|
+
element,
|
|
875
|
+
syncApps() {
|
|
876
|
+
appTabs.render();
|
|
877
|
+
},
|
|
878
|
+
/** The app changed: filters of one app mean nothing in another. */
|
|
879
|
+
appChanged() {
|
|
880
|
+
state.where = {};
|
|
881
|
+
state.eventType = '';
|
|
882
|
+
eventTypePicker.setValue('');
|
|
883
|
+
realtime.data = null;
|
|
884
|
+
state.stale = true;
|
|
885
|
+
if (state.visible) {
|
|
886
|
+
load();
|
|
887
|
+
startRealtime();
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
show() {
|
|
891
|
+
state.visible = true;
|
|
892
|
+
load();
|
|
893
|
+
startRealtime();
|
|
894
|
+
},
|
|
895
|
+
hide() {
|
|
896
|
+
state.visible = false;
|
|
897
|
+
stopRealtime();
|
|
898
|
+
},
|
|
899
|
+
/** Views changed elsewhere (edited, trashed, restored): numbers are out of date. */
|
|
900
|
+
invalidate() {
|
|
901
|
+
state.stale = true;
|
|
902
|
+
if (state.visible) load();
|
|
903
|
+
},
|
|
904
|
+
};
|
|
905
|
+
}
|