@byline/admin 5.1.2 → 5.1.4
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/dist/exports-parity.test.node.js +37 -0
- package/dist/fields/array/array-field.jsx +209 -0
- package/dist/fields/blocks/blocks-field.jsx +248 -0
- package/dist/fields/checkbox/checkbox-field.jsx +24 -0
- package/dist/fields/code/code-editor.jsx +211 -0
- package/dist/fields/code/code-field.jsx +109 -0
- package/dist/fields/column-formatter.jsx +25 -0
- package/dist/fields/date-time-formatter.jsx +18 -0
- package/dist/fields/datetime/datetime-field.jsx +22 -0
- package/dist/fields/draggable-context-menu.jsx +50 -0
- package/dist/fields/field-admin.test.node.js +40 -0
- package/dist/fields/field-renderer.jsx +136 -0
- package/dist/fields/field-services-context.jsx +17 -0
- package/dist/fields/file/file-field.jsx +176 -0
- package/dist/fields/file/file-upload-field.jsx +89 -0
- package/dist/fields/group/group-field.jsx +46 -0
- package/dist/fields/image/image-field.jsx +183 -0
- package/dist/fields/image/image-upload-field.jsx +157 -0
- package/dist/fields/local-date-time.jsx +69 -0
- package/dist/fields/locale-badge.jsx +19 -0
- package/dist/fields/numerical/numerical-field.jsx +93 -0
- package/dist/fields/relation/relation-column-formatter.jsx +74 -0
- package/dist/fields/relation/relation-display.jsx +118 -0
- package/dist/fields/relation/relation-field.jsx +108 -0
- package/dist/fields/relation/relation-many-field.jsx +142 -0
- package/dist/fields/relation/relation-picker.jsx +240 -0
- package/dist/fields/relation/relation-summary.jsx +45 -0
- package/dist/fields/select/select-field.jsx +27 -0
- package/dist/fields/sortable-item.jsx +54 -0
- package/dist/fields/text/text-field.jsx +79 -0
- package/dist/fields/text-area/text-area-field.jsx +79 -0
- package/dist/forms/available-locales-reconcile.test.node.js +43 -0
- package/dist/forms/available-locales-widget.jsx +63 -0
- package/dist/forms/document-actions.jsx +486 -0
- package/dist/forms/form-context.jsx +598 -0
- package/dist/forms/form-modals.jsx +128 -0
- package/dist/forms/form-renderer.jsx +532 -0
- package/dist/forms/form-status-display.jsx +69 -0
- package/dist/forms/navigation-guard.jsx +68 -0
- package/dist/forms/nested-path.test.node.js +115 -0
- package/dist/forms/path-widget.jsx +125 -0
- package/dist/forms/pending-uploads.test.node.js +19 -0
- package/dist/forms/repeating-items.test.node.js +29 -0
- package/dist/forms/scheduled-publication-control.jsx +389 -0
- package/dist/forms/scheduled-publication-state.test.node.js +121 -0
- package/dist/forms/scheduled-publication-time.test.node.js +70 -0
- package/dist/forms/status-transitions.test.node.js +76 -0
- package/dist/forms/tree-placement-widget.jsx +160 -0
- package/dist/forms/upload-executor.test.node.js +368 -0
- package/dist/forms/use-form-layout.test.node.js +69 -0
- package/dist/modules/admin-account/components/change-password.jsx +169 -0
- package/dist/modules/admin-account/components/container.jsx +164 -0
- package/dist/modules/admin-account/components/preferences.jsx +148 -0
- package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
- package/dist/modules/admin-account/components/update.jsx +186 -0
- package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
- package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
- package/dist/modules/admin-roles/components/create.jsx +155 -0
- package/dist/modules/admin-roles/components/permissions.jsx +239 -0
- package/dist/modules/admin-roles/components/update.jsx +148 -0
- package/dist/modules/admin-users/components/create.jsx +206 -0
- package/dist/modules/admin-users/components/roles.jsx +126 -0
- package/dist/modules/admin-users/components/set-password.jsx +133 -0
- package/dist/modules/admin-users/components/update.jsx +215 -0
- package/dist/modules/analytics/components/dashboard.d.ts +13 -3
- package/dist/modules/analytics/components/dashboard.js +86 -68
- package/dist/modules/analytics/components/dashboard.jsx +208 -0
- package/dist/modules/analytics/components/dashboard.module.js +10 -1
- package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
- package/dist/modules/analytics/components/dashboard_module.css +57 -0
- package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
- package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
- package/dist/modules/analytics/components/ranking.d.ts +45 -0
- package/dist/modules/analytics/components/ranking.js +71 -0
- package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -0
- package/dist/modules/analytics/components/timeseries.jsx +193 -0
- package/dist/modules/auth/components/sign-in-form.jsx +89 -0
- package/dist/modules/auth/safe-redirect.test.node.js +40 -0
- package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
- package/dist/presentation/group.jsx +26 -0
- package/dist/presentation/row.jsx +23 -0
- package/dist/presentation/tabs.jsx +39 -0
- package/dist/services/admin-services-context.jsx +17 -0
- package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
- package/dist/widgets/status-badge/status-badge.jsx +46 -0
- package/package.json +7 -7
- package/src/modules/analytics/components/dashboard.module.css +69 -0
- package/src/modules/analytics/components/dashboard.tsx +105 -83
- package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
- package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
- package/src/modules/analytics/components/ranking.test.node.ts +43 -0
- package/src/modules/analytics/components/ranking.tsx +122 -0
|
@@ -4,11 +4,14 @@ import { useEffect, useMemo, useState } from "react";
|
|
|
4
4
|
import { ANALYTICS_DASHBOARD_PERIODS, ANALYTICS_OVERFLOW_KEY, isAnalyticsDashboardPeriod } from "@byline/analytics/config";
|
|
5
5
|
import { ANALYTICS_IGNORE_STORAGE_KEY } from "@byline/analytics-agent";
|
|
6
6
|
import { useTranslation } from "@byline/i18n/react";
|
|
7
|
-
import { Button, Card, Container, Section, Select } from "@byline/ui/react";
|
|
7
|
+
import { Button, Card, Container, Section, Select, useModal } from "@byline/ui/react";
|
|
8
8
|
import clsx from "clsx";
|
|
9
9
|
import dashboard_module from "./dashboard.module.js";
|
|
10
|
+
import { RankedListModal } from "./ranked-list-modal.js";
|
|
11
|
+
import { RankingRows, regionName, shareWidth } from "./ranking.js";
|
|
10
12
|
import { AnalyticsTimeseries, resolveAnalyticsChartGranularity } from "./timeseries.js";
|
|
11
|
-
|
|
13
|
+
const ANALYTICS_PREVIEW_ROWS = 10;
|
|
14
|
+
function AnalyticsDashboard({ data, period, onPeriodChange, loadFullList }) {
|
|
12
15
|
const { locale, t } = useTranslation('byline-admin');
|
|
13
16
|
const [excluded, setExcluded] = useState(false);
|
|
14
17
|
const numbers = useMemo(()=>new Intl.NumberFormat(locale), [
|
|
@@ -37,6 +40,30 @@ function AnalyticsDashboard({ data, period, onPeriodChange }) {
|
|
|
37
40
|
const { views, visitors, downloads } = data.summary;
|
|
38
41
|
const days = data.summary.timeseries.length;
|
|
39
42
|
const chartGranularity = resolveAnalyticsChartGranularity(period, days);
|
|
43
|
+
const fullLists = useMemo(()=>{
|
|
44
|
+
if (!loadFullList) return {};
|
|
45
|
+
const load = (kind)=>()=>loadFullList(kind).then((result)=>({
|
|
46
|
+
rows: result.rows.map(toRankedRow),
|
|
47
|
+
total: result.total
|
|
48
|
+
}));
|
|
49
|
+
return {
|
|
50
|
+
pages: load('pages'),
|
|
51
|
+
downloads: load('downloads'),
|
|
52
|
+
referrers: load('referrers')
|
|
53
|
+
};
|
|
54
|
+
}, [
|
|
55
|
+
loadFullList
|
|
56
|
+
]);
|
|
57
|
+
const countryRows = useMemo(()=>data.countries.map((row)=>({
|
|
58
|
+
key: row.country,
|
|
59
|
+
label: regionName(row.country, locale),
|
|
60
|
+
value: row.views,
|
|
61
|
+
visitors: row.visitors,
|
|
62
|
+
overflow: false
|
|
63
|
+
})), [
|
|
64
|
+
data.countries,
|
|
65
|
+
locale
|
|
66
|
+
]);
|
|
40
67
|
return /*#__PURE__*/ jsx(Section, {
|
|
41
68
|
children: /*#__PURE__*/ jsxs(Container, {
|
|
42
69
|
children: [
|
|
@@ -136,8 +163,9 @@ function AnalyticsDashboard({ data, period, onPeriodChange }) {
|
|
|
136
163
|
caption: t('analytics.columns.viewsAndUniques'),
|
|
137
164
|
tone: "views",
|
|
138
165
|
locale: locale,
|
|
139
|
-
rows: data.pages.rows.map(
|
|
166
|
+
rows: data.pages.rows.map(toRankedRow),
|
|
140
167
|
total: data.pages.total,
|
|
168
|
+
fullList: fullLists.pages,
|
|
141
169
|
coverageFrom: partialCoverageFrom(data.range.from, data.coverage.pathsFrom)
|
|
142
170
|
}),
|
|
143
171
|
/*#__PURE__*/ jsxs("div", {
|
|
@@ -147,27 +175,21 @@ function AnalyticsDashboard({ data, period, onPeriodChange }) {
|
|
|
147
175
|
title: t('analytics.sections.referrers'),
|
|
148
176
|
tone: "visitors",
|
|
149
177
|
locale: locale,
|
|
178
|
+
rows: data.referrers.rows.map(toRankedRow),
|
|
150
179
|
total: data.referrers.total,
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
key: row.referrerHost,
|
|
154
|
-
label: row.referrerHost,
|
|
155
|
-
value: row.views,
|
|
156
|
-
visitors: row.visitors,
|
|
157
|
-
overflow: row.referrerHost === ANALYTICS_OVERFLOW_KEY
|
|
158
|
-
}))
|
|
180
|
+
fullList: fullLists.referrers,
|
|
181
|
+
coverageFrom: partialCoverageFrom(data.range.from, data.coverage.referrersFrom)
|
|
159
182
|
}),
|
|
160
183
|
/*#__PURE__*/ jsx(RankedList, {
|
|
161
184
|
title: t('analytics.sections.countries'),
|
|
162
185
|
tone: "visitors",
|
|
163
186
|
locale: locale,
|
|
164
|
-
rows:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}))
|
|
187
|
+
rows: countryRows,
|
|
188
|
+
total: countryRows.length,
|
|
189
|
+
fullList: {
|
|
190
|
+
rows: countryRows,
|
|
191
|
+
total: countryRows.length
|
|
192
|
+
}
|
|
171
193
|
})
|
|
172
194
|
]
|
|
173
195
|
})
|
|
@@ -178,8 +200,9 @@ function AnalyticsDashboard({ data, period, onPeriodChange }) {
|
|
|
178
200
|
caption: t('analytics.columns.clicksAndUniques'),
|
|
179
201
|
tone: "downloads",
|
|
180
202
|
locale: locale,
|
|
181
|
-
rows: data.downloads.rows.map(
|
|
203
|
+
rows: data.downloads.rows.map(toRankedRow),
|
|
182
204
|
total: data.downloads.total,
|
|
205
|
+
fullList: fullLists.downloads,
|
|
183
206
|
coverageFrom: partialCoverageFrom(data.range.from, data.coverage.pathsFrom)
|
|
184
207
|
})
|
|
185
208
|
]
|
|
@@ -191,11 +214,6 @@ const TONE_TILE = {
|
|
|
191
214
|
visitors: dashboard_module.toneVisitors,
|
|
192
215
|
downloads: dashboard_module.toneDownloads
|
|
193
216
|
};
|
|
194
|
-
const TONE_BAR = {
|
|
195
|
-
views: dashboard_module.barViews,
|
|
196
|
-
visitors: dashboard_module.barVisitors,
|
|
197
|
-
downloads: dashboard_module.barDownloads
|
|
198
|
-
};
|
|
199
217
|
function StatTile({ tone, label, value, foot }) {
|
|
200
218
|
return /*#__PURE__*/ jsxs("div", {
|
|
201
219
|
className: clsx('byline-analytics-stat', dashboard_module.stat, TONE_TILE[tone]),
|
|
@@ -215,22 +233,21 @@ function StatTile({ tone, label, value, foot }) {
|
|
|
215
233
|
]
|
|
216
234
|
});
|
|
217
235
|
}
|
|
218
|
-
function
|
|
236
|
+
function toRankedRow(row) {
|
|
237
|
+
const key = 'path' in row ? row.path : row.referrerHost;
|
|
219
238
|
return {
|
|
220
|
-
key
|
|
221
|
-
label:
|
|
239
|
+
key,
|
|
240
|
+
label: key,
|
|
222
241
|
value: row.views,
|
|
223
242
|
visitors: row.visitors,
|
|
224
|
-
overflow:
|
|
243
|
+
overflow: key === ANALYTICS_OVERFLOW_KEY
|
|
225
244
|
};
|
|
226
245
|
}
|
|
227
|
-
function RankedList({ title, caption, rows, tone, locale, total, coverageFrom }) {
|
|
246
|
+
function RankedList({ title, caption, rows, tone, locale, total, fullList, coverageFrom }) {
|
|
228
247
|
const { t } = useTranslation('byline-admin');
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const ceiling = Math.max(1, ...rows.map((row)=>row.value));
|
|
233
|
-
const truncated = null != total && total > rows.length;
|
|
248
|
+
const modal = useModal();
|
|
249
|
+
const preview = rows.slice(0, ANALYTICS_PREVIEW_ROWS);
|
|
250
|
+
const truncated = total > preview.length;
|
|
234
251
|
const coverageDate = useMemo(()=>new Intl.DateTimeFormat(locale, {
|
|
235
252
|
dateStyle: 'medium',
|
|
236
253
|
timeZone: 'UTC'
|
|
@@ -239,7 +256,7 @@ function RankedList({ title, caption, rows, tone, locale, total, coverageFrom })
|
|
|
239
256
|
]);
|
|
240
257
|
const description = [
|
|
241
258
|
truncated ? t('analytics.topOf', {
|
|
242
|
-
shown:
|
|
259
|
+
shown: preview.length,
|
|
243
260
|
total
|
|
244
261
|
}) : caption,
|
|
245
262
|
null == coverageFrom ? void 0 : t('analytics.coverage.since', {
|
|
@@ -250,51 +267,52 @@ function RankedList({ title, caption, rows, tone, locale, total, coverageFrom })
|
|
|
250
267
|
className: clsx('byline-analytics-list', dashboard_module.list),
|
|
251
268
|
children: [
|
|
252
269
|
/*#__PURE__*/ jsxs(Card.Header, {
|
|
270
|
+
className: clsx('byline-analytics-list-header', dashboard_module.listHeader),
|
|
253
271
|
children: [
|
|
254
|
-
/*#__PURE__*/
|
|
255
|
-
|
|
272
|
+
/*#__PURE__*/ jsxs("div", {
|
|
273
|
+
className: dashboard_module.listHeading,
|
|
274
|
+
children: [
|
|
275
|
+
/*#__PURE__*/ jsx(Card.Title, {
|
|
276
|
+
children: title
|
|
277
|
+
}),
|
|
278
|
+
description.length > 0 && /*#__PURE__*/ jsx(Card.Description, {
|
|
279
|
+
children: description
|
|
280
|
+
})
|
|
281
|
+
]
|
|
256
282
|
}),
|
|
257
|
-
|
|
258
|
-
|
|
283
|
+
null != fullList && truncated && /*#__PURE__*/ jsx(Button, {
|
|
284
|
+
type: "button",
|
|
285
|
+
size: "xs",
|
|
286
|
+
variant: "outlined",
|
|
287
|
+
className: clsx('byline-analytics-view-all', dashboard_module.viewAll),
|
|
288
|
+
onClick: modal.onOpen,
|
|
289
|
+
children: t('analytics.viewAll', {
|
|
290
|
+
count: total
|
|
291
|
+
})
|
|
259
292
|
})
|
|
260
293
|
]
|
|
261
294
|
}),
|
|
262
295
|
/*#__PURE__*/ jsx(Card.Content, {
|
|
263
|
-
children: 0 ===
|
|
296
|
+
children: 0 === preview.length ? /*#__PURE__*/ jsx("p", {
|
|
264
297
|
className: "muted",
|
|
265
298
|
children: t('analytics.empty')
|
|
266
|
-
}) : /*#__PURE__*/ jsx(
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
style: {
|
|
271
|
-
'--byline-analytics-share': `${shareWidth(row.value, ceiling)}%`
|
|
272
|
-
},
|
|
273
|
-
children: [
|
|
274
|
-
/*#__PURE__*/ jsx("span", {
|
|
275
|
-
className: dashboard_module.rankingLabel,
|
|
276
|
-
title: row.key,
|
|
277
|
-
children: row.overflow ? t('analytics.overflow') : row.label
|
|
278
|
-
}),
|
|
279
|
-
/*#__PURE__*/ jsx("span", {
|
|
280
|
-
className: dashboard_module.rankingValue,
|
|
281
|
-
children: numbers.format(row.value)
|
|
282
|
-
}),
|
|
283
|
-
/*#__PURE__*/ jsx("span", {
|
|
284
|
-
className: dashboard_module.rankingVisitors,
|
|
285
|
-
children: numbers.format(row.visitors)
|
|
286
|
-
})
|
|
287
|
-
]
|
|
288
|
-
}, row.key))
|
|
299
|
+
}) : /*#__PURE__*/ jsx(RankingRows, {
|
|
300
|
+
rows: preview,
|
|
301
|
+
tone: tone,
|
|
302
|
+
locale: locale
|
|
289
303
|
})
|
|
304
|
+
}),
|
|
305
|
+
null != fullList && /*#__PURE__*/ jsx(RankedListModal, {
|
|
306
|
+
isOpen: modal.isOpen,
|
|
307
|
+
onDismiss: modal.onDismiss,
|
|
308
|
+
title: title,
|
|
309
|
+
tone: tone,
|
|
310
|
+
locale: locale,
|
|
311
|
+
source: fullList
|
|
290
312
|
})
|
|
291
313
|
]
|
|
292
314
|
});
|
|
293
315
|
}
|
|
294
|
-
function shareWidth(value, ceiling) {
|
|
295
|
-
if (!Number.isFinite(value) || value <= 0 || ceiling <= 0) return 0;
|
|
296
|
-
return Math.max(3, Math.min(100, value / ceiling * 100));
|
|
297
|
-
}
|
|
298
316
|
function formatShare(part, whole, locale) {
|
|
299
317
|
const percent = new Intl.NumberFormat(locale, {
|
|
300
318
|
style: 'percent',
|
|
@@ -305,4 +323,4 @@ function formatShare(part, whole, locale) {
|
|
|
305
323
|
function partialCoverageFrom(rangeFrom, coverageFrom) {
|
|
306
324
|
return null != coverageFrom && coverageFrom > rangeFrom ? coverageFrom : void 0;
|
|
307
325
|
}
|
|
308
|
-
export { AnalyticsDashboard, formatShare, partialCoverageFrom, shareWidth };
|
|
326
|
+
export { ANALYTICS_PREVIEW_ROWS, AnalyticsDashboard, formatShare, partialCoverageFrom, shareWidth };
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { ANALYTICS_DASHBOARD_PERIODS, ANALYTICS_OVERFLOW_KEY, isAnalyticsDashboardPeriod, } from '@byline/analytics/config';
|
|
4
|
+
import { ANALYTICS_IGNORE_STORAGE_KEY } from '@byline/analytics-agent';
|
|
5
|
+
import { useTranslation } from '@byline/i18n/react';
|
|
6
|
+
import { Button, Card, Container, Section, Select } from '@byline/ui/react';
|
|
7
|
+
import cx from 'clsx';
|
|
8
|
+
import styles from './dashboard.module.css';
|
|
9
|
+
import { AnalyticsTimeseries, resolveAnalyticsChartGranularity } from './timeseries.js';
|
|
10
|
+
export function AnalyticsDashboard({ data, period, onPeriodChange, }) {
|
|
11
|
+
const { locale, t } = useTranslation('byline-admin');
|
|
12
|
+
const [excluded, setExcluded] = useState(false);
|
|
13
|
+
const numbers = useMemo(() => new Intl.NumberFormat(locale), [locale]);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
try {
|
|
16
|
+
setExcluded(localStorage.getItem(ANALYTICS_IGNORE_STORAGE_KEY) != null);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
setExcluded(false);
|
|
20
|
+
}
|
|
21
|
+
}, []);
|
|
22
|
+
const toggleExclusion = () => {
|
|
23
|
+
try {
|
|
24
|
+
if (excluded)
|
|
25
|
+
localStorage.removeItem(ANALYTICS_IGNORE_STORAGE_KEY);
|
|
26
|
+
else
|
|
27
|
+
localStorage.setItem(ANALYTICS_IGNORE_STORAGE_KEY, '1');
|
|
28
|
+
setExcluded(!excluded);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// A blocked storage surface leaves the current collection behavior unchanged.
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const periodItems = ANALYTICS_DASHBOARD_PERIODS.map((value) => ({
|
|
35
|
+
value: String(value),
|
|
36
|
+
label: typeof value === 'number'
|
|
37
|
+
? t('analytics.period.days', { count: value })
|
|
38
|
+
: t(`analytics.period.${value}`),
|
|
39
|
+
}));
|
|
40
|
+
const { views, visitors, downloads } = data.summary;
|
|
41
|
+
const days = data.summary.timeseries.length;
|
|
42
|
+
const chartGranularity = resolveAnalyticsChartGranularity(period, days);
|
|
43
|
+
return (<Section>
|
|
44
|
+
<Container>
|
|
45
|
+
<header className={cx('byline-analytics-header', styles.header)}>
|
|
46
|
+
<div>
|
|
47
|
+
<h1 className={cx('byline-analytics-title', styles.title)}>{t('analytics.title')}</h1>
|
|
48
|
+
<p className={cx('muted', 'byline-analytics-help', styles.help)}>
|
|
49
|
+
{t('analytics.dailyUniquesHelp')}
|
|
50
|
+
</p>
|
|
51
|
+
</div>
|
|
52
|
+
<div className={cx('byline-analytics-controls', styles.controls)}>
|
|
53
|
+
<Select id="analytics-period" name="analytics-period" aria-label={t('analytics.period.label')} size="sm" value={String(period)} items={periodItems} onValueChange={(value) => {
|
|
54
|
+
const next = value === 'ytd' || value === 'all' ? value : Number(value);
|
|
55
|
+
if (isAnalyticsDashboardPeriod(next))
|
|
56
|
+
onPeriodChange(next);
|
|
57
|
+
}}/>
|
|
58
|
+
<Button type="button" size="sm" aria-pressed={excluded}
|
|
59
|
+
// Local storage is origin-scoped, so this only governs public-page
|
|
60
|
+
// collection when the admin and the public site share an origin.
|
|
61
|
+
title={t('analytics.exclusion.help')} onClick={toggleExclusion}>
|
|
62
|
+
{excluded ? t('analytics.exclusion.include') : t('analytics.exclusion.exclude')}
|
|
63
|
+
</Button>
|
|
64
|
+
</div>
|
|
65
|
+
</header>
|
|
66
|
+
|
|
67
|
+
{/* Tinted ground, saturated ink, tracked label, tabular number — the
|
|
68
|
+
same tile grammar as the collection dashboard's status counts. */}
|
|
69
|
+
<div className={cx('byline-analytics-stats', styles.stats)}>
|
|
70
|
+
<StatTile tone="views" label={t('analytics.stats.views')} value={numbers.format(views)} foot={t('analytics.stats.perDay', {
|
|
71
|
+
count: days === 0 ? 0 : Math.round(views / days),
|
|
72
|
+
})}/>
|
|
73
|
+
<StatTile tone="visitors" label={t('analytics.stats.dailyUniques')} value={numbers.format(visitors)}
|
|
74
|
+
// The qualification rides under the figure it qualifies rather
|
|
75
|
+
// than sitting in help text several elements away.
|
|
76
|
+
foot={t('analytics.stats.sumOfDays', { count: days })}/>
|
|
77
|
+
<StatTile tone="downloads" label={t('analytics.stats.downloads')} value={numbers.format(downloads)} foot={t('analytics.stats.shareOfViews', {
|
|
78
|
+
share: formatShare(downloads, views, locale),
|
|
79
|
+
})}/>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<Card className={cx('byline-analytics-chart-card', styles.chartCard)}>
|
|
83
|
+
<Card.Header>
|
|
84
|
+
<Card.Title>
|
|
85
|
+
{chartGranularity === 'day'
|
|
86
|
+
? t('analytics.chart.perDay')
|
|
87
|
+
: chartGranularity === 'seven-day'
|
|
88
|
+
? t('analytics.chart.perSevenDays')
|
|
89
|
+
: t('analytics.chart.perMonth')}
|
|
90
|
+
</Card.Title>
|
|
91
|
+
</Card.Header>
|
|
92
|
+
<Card.Content>
|
|
93
|
+
<AnalyticsTimeseries days={data.summary.timeseries} granularity={chartGranularity} locale={locale}/>
|
|
94
|
+
</Card.Content>
|
|
95
|
+
</Card>
|
|
96
|
+
|
|
97
|
+
{/* Top pages is the list people actually read, so it gets the wide
|
|
98
|
+
column; referrers and countries stack beside it. */}
|
|
99
|
+
<div className={cx('byline-analytics-lists', styles.lists)}>
|
|
100
|
+
<RankedList title={t('analytics.sections.pages')} caption={t('analytics.columns.viewsAndUniques')} tone="views" locale={locale} rows={data.pages.rows.map(toPathRow)} total={data.pages.total} coverageFrom={partialCoverageFrom(data.range.from, data.coverage.pathsFrom)}/>
|
|
101
|
+
<div className={styles.stack}>
|
|
102
|
+
<RankedList title={t('analytics.sections.referrers')} tone="visitors" locale={locale} total={data.referrers.total} coverageFrom={partialCoverageFrom(data.range.from, data.coverage.referrersFrom)} rows={data.referrers.rows.map((row) => ({
|
|
103
|
+
key: row.referrerHost,
|
|
104
|
+
label: row.referrerHost,
|
|
105
|
+
value: row.views,
|
|
106
|
+
visitors: row.visitors,
|
|
107
|
+
overflow: row.referrerHost === ANALYTICS_OVERFLOW_KEY,
|
|
108
|
+
}))}/>
|
|
109
|
+
<RankedList title={t('analytics.sections.countries')} tone="visitors" locale={locale} rows={data.countries.map((row) => ({
|
|
110
|
+
key: row.country,
|
|
111
|
+
label: row.country,
|
|
112
|
+
value: row.views,
|
|
113
|
+
visitors: row.visitors,
|
|
114
|
+
overflow: false,
|
|
115
|
+
}))}/>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<RankedList title={t('analytics.sections.downloads')} caption={t('analytics.columns.clicksAndUniques')} tone="downloads" locale={locale} rows={data.downloads.rows.map(toPathRow)} total={data.downloads.total} coverageFrom={partialCoverageFrom(data.range.from, data.coverage.pathsFrom)}/>
|
|
120
|
+
</Container>
|
|
121
|
+
</Section>);
|
|
122
|
+
}
|
|
123
|
+
const TONE_TILE = {
|
|
124
|
+
views: styles.toneViews,
|
|
125
|
+
visitors: styles.toneVisitors,
|
|
126
|
+
downloads: styles.toneDownloads,
|
|
127
|
+
};
|
|
128
|
+
const TONE_BAR = {
|
|
129
|
+
views: styles.barViews,
|
|
130
|
+
visitors: styles.barVisitors,
|
|
131
|
+
downloads: styles.barDownloads,
|
|
132
|
+
};
|
|
133
|
+
function StatTile({ tone, label, value, foot, }) {
|
|
134
|
+
return (<div className={cx('byline-analytics-stat', styles.stat, TONE_TILE[tone])}>
|
|
135
|
+
<span className={cx('byline-analytics-stat-label', styles.statLabel)}>{label}</span>
|
|
136
|
+
<span className={cx('byline-analytics-stat-value', styles.statValue)}>{value}</span>
|
|
137
|
+
<span className={cx('byline-analytics-stat-foot', styles.statFoot)}>{foot}</span>
|
|
138
|
+
</div>);
|
|
139
|
+
}
|
|
140
|
+
function toPathRow(row) {
|
|
141
|
+
return {
|
|
142
|
+
key: row.path,
|
|
143
|
+
label: row.path,
|
|
144
|
+
value: row.views,
|
|
145
|
+
visitors: row.visitors,
|
|
146
|
+
overflow: row.path === ANALYTICS_OVERFLOW_KEY,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function RankedList({ title, caption, rows, tone, locale, total, coverageFrom, }) {
|
|
150
|
+
const { t } = useTranslation('byline-admin');
|
|
151
|
+
const numbers = useMemo(() => new Intl.NumberFormat(locale), [locale]);
|
|
152
|
+
const ceiling = Math.max(1, ...rows.map((row) => row.value));
|
|
153
|
+
// Say so when the list is a top-N slice. Without this the card presents a
|
|
154
|
+
// truncated ranking as though it were the whole set.
|
|
155
|
+
const truncated = total != null && total > rows.length;
|
|
156
|
+
const coverageDate = useMemo(() => new Intl.DateTimeFormat(locale, {
|
|
157
|
+
dateStyle: 'medium',
|
|
158
|
+
timeZone: 'UTC',
|
|
159
|
+
}), [locale]);
|
|
160
|
+
const description = [
|
|
161
|
+
truncated ? t('analytics.topOf', { shown: rows.length, total }) : caption,
|
|
162
|
+
coverageFrom == null
|
|
163
|
+
? undefined
|
|
164
|
+
: t('analytics.coverage.since', {
|
|
165
|
+
date: coverageDate.format(new Date(`${coverageFrom}T00:00:00.000Z`)),
|
|
166
|
+
}),
|
|
167
|
+
]
|
|
168
|
+
.filter((value) => value != null)
|
|
169
|
+
.join(' · ');
|
|
170
|
+
return (<Card className={cx('byline-analytics-list', styles.list)}>
|
|
171
|
+
<Card.Header>
|
|
172
|
+
<Card.Title>{title}</Card.Title>
|
|
173
|
+
{description.length > 0 && <Card.Description>{description}</Card.Description>}
|
|
174
|
+
</Card.Header>
|
|
175
|
+
<Card.Content>
|
|
176
|
+
{rows.length === 0 ? (<p className="muted">{t('analytics.empty')}</p>) : (<ol className={cx('byline-analytics-ranking', styles.ranking)}>
|
|
177
|
+
{rows.map((row) => (<li key={row.key} className={cx('byline-analytics-ranking-row', styles.rankingRow, TONE_BAR[tone], row.overflow && styles.rankingOverflow)}
|
|
178
|
+
// The share bar sits behind the row so the label and its
|
|
179
|
+
// magnitude occupy one line and are read together.
|
|
180
|
+
style={{
|
|
181
|
+
'--byline-analytics-share': `${shareWidth(row.value, ceiling)}%`,
|
|
182
|
+
}}>
|
|
183
|
+
<span className={styles.rankingLabel} title={row.key}>
|
|
184
|
+
{/* `__other__` is a reserved aggregate, not a page anyone
|
|
185
|
+
visited — never render it as though it were a real path. */}
|
|
186
|
+
{row.overflow ? t('analytics.overflow') : row.label}
|
|
187
|
+
</span>
|
|
188
|
+
<span className={styles.rankingValue}>{numbers.format(row.value)}</span>
|
|
189
|
+
<span className={styles.rankingVisitors}>{numbers.format(row.visitors)}</span>
|
|
190
|
+
</li>))}
|
|
191
|
+
</ol>)}
|
|
192
|
+
</Card.Content>
|
|
193
|
+
</Card>);
|
|
194
|
+
}
|
|
195
|
+
/** Never collapse the bar entirely: a visible sliver still encodes "smallest". */
|
|
196
|
+
export function shareWidth(value, ceiling) {
|
|
197
|
+
if (!Number.isFinite(value) || value <= 0 || ceiling <= 0)
|
|
198
|
+
return 0;
|
|
199
|
+
return Math.max(3, Math.min(100, (value / ceiling) * 100));
|
|
200
|
+
}
|
|
201
|
+
export function formatShare(part, whole, locale) {
|
|
202
|
+
const percent = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 1 });
|
|
203
|
+
return percent.format(whole <= 0 ? 0 : part / whole);
|
|
204
|
+
}
|
|
205
|
+
/** Return the retained boundary only when it truncates the selected range. */
|
|
206
|
+
export function partialCoverageFrom(rangeFrom, coverageFrom) {
|
|
207
|
+
return coverageFrom != null && coverageFrom > rangeFrom ? coverageFrom : undefined;
|
|
208
|
+
}
|
|
@@ -24,6 +24,15 @@ const dashboard_module = {
|
|
|
24
24
|
rankingOverflow: "rankingOverflow-hUQMKU",
|
|
25
25
|
rankingLabel: "rankingLabel-wsuniC",
|
|
26
26
|
rankingValue: "rankingValue-i_vmgf",
|
|
27
|
-
rankingVisitors: "rankingVisitors-NZl_jj"
|
|
27
|
+
rankingVisitors: "rankingVisitors-NZl_jj",
|
|
28
|
+
listHeader: "listHeader-nWIpzD",
|
|
29
|
+
listHeading: "listHeading-ljSubR",
|
|
30
|
+
viewAll: "viewAll-jPtoC0",
|
|
31
|
+
modal: "modal-_Fpti3",
|
|
32
|
+
modalCaption: "modalCaption-Z7Qsck",
|
|
33
|
+
modalContent: "modalContent-raK5q1",
|
|
34
|
+
modalStatus: "modalStatus-bXbZr9",
|
|
35
|
+
modalActions: "modalActions-XVBrOS",
|
|
36
|
+
srOnly: "srOnly-szSAhX"
|
|
28
37
|
};
|
|
29
38
|
export default dashboard_module;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { formatShare, partialCoverageFrom, shareWidth } from './dashboard.js';
|
|
10
|
+
import { bucketAnalyticsTimeseries, buildAnalyticsColumns, resolveAnalyticsChartGranularity, } from './timeseries.js';
|
|
11
|
+
function day(date, views, visitors) {
|
|
12
|
+
return { day: date, views, visitors, downloads: 0 };
|
|
13
|
+
}
|
|
14
|
+
describe('buildAnalyticsColumns', () => {
|
|
15
|
+
it('scales every column against the tallest day in the window', () => {
|
|
16
|
+
const columns = buildAnalyticsColumns([
|
|
17
|
+
day('2026-08-20', 0, 0),
|
|
18
|
+
day('2026-08-21', 5, 3),
|
|
19
|
+
day('2026-08-22', 10, 4),
|
|
20
|
+
]);
|
|
21
|
+
expect(columns).toHaveLength(3);
|
|
22
|
+
expect(columns[0]?.height).toBe(0);
|
|
23
|
+
expect(columns[1]?.height).toBe(90);
|
|
24
|
+
expect(columns[2]?.height).toBe(180);
|
|
25
|
+
// The tallest column reaches the top of the plot, and every column sits on
|
|
26
|
+
// the baseline: y + height is the full viewBox height.
|
|
27
|
+
for (const column of columns) {
|
|
28
|
+
expect(column.y + column.height).toBeCloseTo(180);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
it('keeps the unique-visitor mark inset within its own day', () => {
|
|
32
|
+
const [column] = buildAnalyticsColumns([day('2026-08-20', 10, 4)]);
|
|
33
|
+
if (column == null)
|
|
34
|
+
throw new Error('expected one column');
|
|
35
|
+
// Visitors can never exceed that day's views, so the inset mark is always
|
|
36
|
+
// shorter and narrower than the column it sits inside.
|
|
37
|
+
expect(column.insetHeight).toBeLessThan(column.height);
|
|
38
|
+
expect(column.insetWidth).toBeLessThan(column.width);
|
|
39
|
+
expect(column.insetX).toBeGreaterThan(column.x);
|
|
40
|
+
expect(column.insetX + column.insetWidth).toBeLessThan(column.x + column.width);
|
|
41
|
+
});
|
|
42
|
+
it('survives an all-zero window without dividing by zero', () => {
|
|
43
|
+
const columns = buildAnalyticsColumns([day('2026-08-20', 0, 0), day('2026-08-21', 0, 0)]);
|
|
44
|
+
expect(columns.map((column) => column.height)).toEqual([0, 0]);
|
|
45
|
+
expect(columns.every((column) => Number.isFinite(column.x))).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
it('returns nothing for an empty window', () => {
|
|
48
|
+
expect(buildAnalyticsColumns([])).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
it('spans the full plot width and never overlaps neighbouring hit targets', () => {
|
|
51
|
+
const columns = buildAnalyticsColumns([
|
|
52
|
+
day('2026-08-20', 1, 1),
|
|
53
|
+
day('2026-08-21', 2, 1),
|
|
54
|
+
day('2026-08-22', 3, 2),
|
|
55
|
+
]);
|
|
56
|
+
expect(columns[0]?.hitX).toBe(0);
|
|
57
|
+
const last = columns[columns.length - 1];
|
|
58
|
+
expect((last?.hitX ?? 0) + (last?.hitWidth ?? 0)).toBeCloseTo(900);
|
|
59
|
+
for (let index = 1; index < columns.length; index += 1) {
|
|
60
|
+
const previous = columns[index - 1];
|
|
61
|
+
expect(columns[index]?.hitX).toBeCloseTo((previous?.hitX ?? 0) + (previous?.hitWidth ?? 0));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('analytics chart buckets', () => {
|
|
66
|
+
it('selects granularity explicitly from the reporting period and range size', () => {
|
|
67
|
+
expect(resolveAnalyticsChartGranularity(90, 90)).toBe('day');
|
|
68
|
+
expect(resolveAnalyticsChartGranularity('ytd', 90)).toBe('seven-day');
|
|
69
|
+
expect(resolveAnalyticsChartGranularity('all', 90)).toBe('day');
|
|
70
|
+
expect(resolveAnalyticsChartGranularity('all', 91)).toBe('seven-day');
|
|
71
|
+
expect(resolveAnalyticsChartGranularity('all', 733)).toBe('month');
|
|
72
|
+
});
|
|
73
|
+
it('keeps day boundaries and width in daily buckets', () => {
|
|
74
|
+
expect(bucketAnalyticsTimeseries([day('2026-01-01', 2, 1)], 'day')).toEqual([
|
|
75
|
+
{
|
|
76
|
+
from: '2026-01-01',
|
|
77
|
+
to: '2026-01-01',
|
|
78
|
+
granularity: 'day',
|
|
79
|
+
dayCount: 1,
|
|
80
|
+
views: 2,
|
|
81
|
+
visitors: 1,
|
|
82
|
+
downloads: 0,
|
|
83
|
+
},
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
it('sums daily rows into explicit seven-day buckets', () => {
|
|
87
|
+
const days = Array.from({ length: 8 }, (_, index) => day(`2026-01-${String(index + 1).padStart(2, '0')}`, 2, 1));
|
|
88
|
+
expect(bucketAnalyticsTimeseries(days, 'seven-day')).toEqual([
|
|
89
|
+
{
|
|
90
|
+
from: '2026-01-01',
|
|
91
|
+
to: '2026-01-07',
|
|
92
|
+
granularity: 'seven-day',
|
|
93
|
+
dayCount: 7,
|
|
94
|
+
views: 14,
|
|
95
|
+
visitors: 7,
|
|
96
|
+
downloads: 0,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
from: '2026-01-08',
|
|
100
|
+
to: '2026-01-08',
|
|
101
|
+
granularity: 'seven-day',
|
|
102
|
+
dayCount: 1,
|
|
103
|
+
views: 2,
|
|
104
|
+
visitors: 1,
|
|
105
|
+
downloads: 0,
|
|
106
|
+
},
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
it('aligns month buckets to UTC calendar boundaries', () => {
|
|
110
|
+
expect(bucketAnalyticsTimeseries([day('2026-01-31', 2, 1), day('2026-02-01', 3, 2), day('2026-02-02', 4, 2)], 'month')).toEqual([
|
|
111
|
+
{
|
|
112
|
+
from: '2026-01-31',
|
|
113
|
+
to: '2026-01-31',
|
|
114
|
+
granularity: 'month',
|
|
115
|
+
dayCount: 1,
|
|
116
|
+
views: 2,
|
|
117
|
+
visitors: 1,
|
|
118
|
+
downloads: 0,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
from: '2026-02-01',
|
|
122
|
+
to: '2026-02-02',
|
|
123
|
+
granularity: 'month',
|
|
124
|
+
dayCount: 2,
|
|
125
|
+
views: 7,
|
|
126
|
+
visitors: 4,
|
|
127
|
+
downloads: 0,
|
|
128
|
+
},
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
describe('shareWidth', () => {
|
|
133
|
+
it('scales a row against the largest row in its list', () => {
|
|
134
|
+
expect(shareWidth(50, 100)).toBe(50);
|
|
135
|
+
expect(shareWidth(100, 100)).toBe(100);
|
|
136
|
+
});
|
|
137
|
+
it('keeps the smallest row visible rather than collapsing it', () => {
|
|
138
|
+
expect(shareWidth(1, 10_000)).toBe(3);
|
|
139
|
+
});
|
|
140
|
+
it('returns no bar for absent or impossible values', () => {
|
|
141
|
+
expect(shareWidth(0, 100)).toBe(0);
|
|
142
|
+
expect(shareWidth(5, 0)).toBe(0);
|
|
143
|
+
expect(shareWidth(Number.NaN, 100)).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
describe('formatShare', () => {
|
|
147
|
+
it('renders a locale-aware percentage of the whole', () => {
|
|
148
|
+
expect(formatShare(96, 1_200, 'en-US')).toBe('8%');
|
|
149
|
+
expect(formatShare(1, 3, 'en-US')).toBe('33.3%');
|
|
150
|
+
});
|
|
151
|
+
it('reports zero rather than NaN when there is nothing to divide', () => {
|
|
152
|
+
expect(formatShare(0, 0, 'en-US')).toBe('0%');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe('partialCoverageFrom', () => {
|
|
156
|
+
it('returns a retained boundary only when it truncates the report', () => {
|
|
157
|
+
expect(partialCoverageFrom('2025-01-01', '2026-05-26')).toBe('2026-05-26');
|
|
158
|
+
expect(partialCoverageFrom('2026-05-27', '2026-05-26')).toBeUndefined();
|
|
159
|
+
expect(partialCoverageFrom('2026-05-26', '2026-05-26')).toBeUndefined();
|
|
160
|
+
expect(partialCoverageFrom('2025-01-01', null)).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
});
|