@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.
Files changed (93) hide show
  1. package/dist/exports-parity.test.node.js +37 -0
  2. package/dist/fields/array/array-field.jsx +209 -0
  3. package/dist/fields/blocks/blocks-field.jsx +248 -0
  4. package/dist/fields/checkbox/checkbox-field.jsx +24 -0
  5. package/dist/fields/code/code-editor.jsx +211 -0
  6. package/dist/fields/code/code-field.jsx +109 -0
  7. package/dist/fields/column-formatter.jsx +25 -0
  8. package/dist/fields/date-time-formatter.jsx +18 -0
  9. package/dist/fields/datetime/datetime-field.jsx +22 -0
  10. package/dist/fields/draggable-context-menu.jsx +50 -0
  11. package/dist/fields/field-admin.test.node.js +40 -0
  12. package/dist/fields/field-renderer.jsx +136 -0
  13. package/dist/fields/field-services-context.jsx +17 -0
  14. package/dist/fields/file/file-field.jsx +176 -0
  15. package/dist/fields/file/file-upload-field.jsx +89 -0
  16. package/dist/fields/group/group-field.jsx +46 -0
  17. package/dist/fields/image/image-field.jsx +183 -0
  18. package/dist/fields/image/image-upload-field.jsx +157 -0
  19. package/dist/fields/local-date-time.jsx +69 -0
  20. package/dist/fields/locale-badge.jsx +19 -0
  21. package/dist/fields/numerical/numerical-field.jsx +93 -0
  22. package/dist/fields/relation/relation-column-formatter.jsx +74 -0
  23. package/dist/fields/relation/relation-display.jsx +118 -0
  24. package/dist/fields/relation/relation-field.jsx +108 -0
  25. package/dist/fields/relation/relation-many-field.jsx +142 -0
  26. package/dist/fields/relation/relation-picker.jsx +240 -0
  27. package/dist/fields/relation/relation-summary.jsx +45 -0
  28. package/dist/fields/select/select-field.jsx +27 -0
  29. package/dist/fields/sortable-item.jsx +54 -0
  30. package/dist/fields/text/text-field.jsx +79 -0
  31. package/dist/fields/text-area/text-area-field.jsx +79 -0
  32. package/dist/forms/available-locales-reconcile.test.node.js +43 -0
  33. package/dist/forms/available-locales-widget.jsx +63 -0
  34. package/dist/forms/document-actions.jsx +486 -0
  35. package/dist/forms/form-context.jsx +598 -0
  36. package/dist/forms/form-modals.jsx +128 -0
  37. package/dist/forms/form-renderer.jsx +532 -0
  38. package/dist/forms/form-status-display.jsx +69 -0
  39. package/dist/forms/navigation-guard.jsx +68 -0
  40. package/dist/forms/nested-path.test.node.js +115 -0
  41. package/dist/forms/path-widget.jsx +125 -0
  42. package/dist/forms/pending-uploads.test.node.js +19 -0
  43. package/dist/forms/repeating-items.test.node.js +29 -0
  44. package/dist/forms/scheduled-publication-control.jsx +389 -0
  45. package/dist/forms/scheduled-publication-state.test.node.js +121 -0
  46. package/dist/forms/scheduled-publication-time.test.node.js +70 -0
  47. package/dist/forms/status-transitions.test.node.js +76 -0
  48. package/dist/forms/tree-placement-widget.jsx +160 -0
  49. package/dist/forms/upload-executor.test.node.js +368 -0
  50. package/dist/forms/use-form-layout.test.node.js +69 -0
  51. package/dist/modules/admin-account/components/change-password.jsx +169 -0
  52. package/dist/modules/admin-account/components/container.jsx +164 -0
  53. package/dist/modules/admin-account/components/preferences.jsx +148 -0
  54. package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
  55. package/dist/modules/admin-account/components/update.jsx +186 -0
  56. package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
  57. package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
  58. package/dist/modules/admin-roles/components/create.jsx +155 -0
  59. package/dist/modules/admin-roles/components/permissions.jsx +239 -0
  60. package/dist/modules/admin-roles/components/update.jsx +148 -0
  61. package/dist/modules/admin-users/components/create.jsx +206 -0
  62. package/dist/modules/admin-users/components/roles.jsx +126 -0
  63. package/dist/modules/admin-users/components/set-password.jsx +133 -0
  64. package/dist/modules/admin-users/components/update.jsx +215 -0
  65. package/dist/modules/analytics/components/dashboard.d.ts +13 -3
  66. package/dist/modules/analytics/components/dashboard.js +86 -68
  67. package/dist/modules/analytics/components/dashboard.jsx +208 -0
  68. package/dist/modules/analytics/components/dashboard.module.js +10 -1
  69. package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
  70. package/dist/modules/analytics/components/dashboard_module.css +57 -0
  71. package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
  72. package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
  73. package/dist/modules/analytics/components/ranking.d.ts +45 -0
  74. package/dist/modules/analytics/components/ranking.js +71 -0
  75. package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -0
  76. package/dist/modules/analytics/components/timeseries.jsx +193 -0
  77. package/dist/modules/auth/components/sign-in-form.jsx +89 -0
  78. package/dist/modules/auth/safe-redirect.test.node.js +40 -0
  79. package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
  80. package/dist/presentation/group.jsx +26 -0
  81. package/dist/presentation/row.jsx +23 -0
  82. package/dist/presentation/tabs.jsx +39 -0
  83. package/dist/services/admin-services-context.jsx +17 -0
  84. package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
  85. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
  86. package/dist/widgets/status-badge/status-badge.jsx +46 -0
  87. package/package.json +7 -7
  88. package/src/modules/analytics/components/dashboard.module.css +69 -0
  89. package/src/modules/analytics/components/dashboard.tsx +105 -83
  90. package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
  91. package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
  92. package/src/modules/analytics/components/ranking.test.node.ts +43 -0
  93. 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
- function AnalyticsDashboard({ data, period, onPeriodChange }) {
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(toPathRow),
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
- coverageFrom: partialCoverageFrom(data.range.from, data.coverage.referrersFrom),
152
- rows: data.referrers.rows.map((row)=>({
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: data.countries.map((row)=>({
165
- key: row.country,
166
- label: row.country,
167
- value: row.views,
168
- visitors: row.visitors,
169
- overflow: false
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(toPathRow),
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 toPathRow(row) {
236
+ function toRankedRow(row) {
237
+ const key = 'path' in row ? row.path : row.referrerHost;
219
238
  return {
220
- key: row.path,
221
- label: row.path,
239
+ key,
240
+ label: key,
222
241
  value: row.views,
223
242
  visitors: row.visitors,
224
- overflow: row.path === ANALYTICS_OVERFLOW_KEY
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 numbers = useMemo(()=>new Intl.NumberFormat(locale), [
230
- locale
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: rows.length,
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__*/ jsx(Card.Title, {
255
- children: title
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
- description.length > 0 && /*#__PURE__*/ jsx(Card.Description, {
258
- children: description
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 === rows.length ? /*#__PURE__*/ jsx("p", {
296
+ children: 0 === preview.length ? /*#__PURE__*/ jsx("p", {
264
297
  className: "muted",
265
298
  children: t('analytics.empty')
266
- }) : /*#__PURE__*/ jsx("ol", {
267
- className: clsx('byline-analytics-ranking', dashboard_module.ranking),
268
- children: rows.map((row)=>/*#__PURE__*/ jsxs("li", {
269
- className: clsx('byline-analytics-ranking-row', dashboard_module.rankingRow, TONE_BAR[tone], row.overflow && dashboard_module.rankingOverflow),
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
+ });