@byline/admin 4.15.0 → 4.16.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/dist/abilities.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/modules/analytics/abilities.d.ts +14 -0
- package/dist/modules/analytics/abilities.js +21 -0
- package/dist/modules/analytics/components/dashboard.d.ts +20 -0
- package/dist/modules/analytics/components/dashboard.js +308 -0
- package/dist/modules/analytics/components/dashboard.module.js +29 -0
- package/dist/modules/analytics/components/dashboard.test.node.d.ts +8 -0
- package/dist/modules/analytics/components/dashboard_module.css +268 -0
- package/dist/modules/analytics/components/timeseries.d.ts +59 -0
- package/dist/modules/analytics/components/timeseries.js +253 -0
- package/dist/modules/analytics/components/timeseries.module.js +17 -0
- package/dist/modules/analytics/components/timeseries_module.css +126 -0
- package/dist/modules/analytics/index.d.ts +9 -0
- package/dist/modules/analytics/index.js +1 -0
- package/dist/modules/analytics/types.d.ts +20 -0
- package/dist/modules/analytics/types.js +1 -0
- package/package.json +17 -5
- package/src/abilities.ts +2 -0
- package/src/index.ts +1 -0
- package/src/modules/analytics/abilities.ts +33 -0
- package/src/modules/analytics/components/dashboard.module.css +330 -0
- package/src/modules/analytics/components/dashboard.test.node.ts +193 -0
- package/src/modules/analytics/components/dashboard.tsx +371 -0
- package/src/modules/analytics/components/timeseries.module.css +151 -0
- package/src/modules/analytics/components/timeseries.tsx +332 -0
- package/src/modules/analytics/index.ts +14 -0
- package/src/modules/analytics/types.ts +31 -0
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
|
|
9
|
+
import type { AnalyticsSummaryDay } from '@byline/analytics'
|
|
10
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
|
|
12
|
+
import { formatShare, partialCoverageFrom, shareWidth } from './dashboard.js'
|
|
13
|
+
import {
|
|
14
|
+
bucketAnalyticsTimeseries,
|
|
15
|
+
buildAnalyticsColumns,
|
|
16
|
+
resolveAnalyticsChartGranularity,
|
|
17
|
+
} from './timeseries.js'
|
|
18
|
+
|
|
19
|
+
function day(date: string, views: number, visitors: number): AnalyticsSummaryDay {
|
|
20
|
+
return { day: date, views, visitors, downloads: 0 }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('buildAnalyticsColumns', () => {
|
|
24
|
+
it('scales every column against the tallest day in the window', () => {
|
|
25
|
+
const columns = buildAnalyticsColumns([
|
|
26
|
+
day('2026-08-20', 0, 0),
|
|
27
|
+
day('2026-08-21', 5, 3),
|
|
28
|
+
day('2026-08-22', 10, 4),
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
expect(columns).toHaveLength(3)
|
|
32
|
+
expect(columns[0]?.height).toBe(0)
|
|
33
|
+
expect(columns[1]?.height).toBe(90)
|
|
34
|
+
expect(columns[2]?.height).toBe(180)
|
|
35
|
+
// The tallest column reaches the top of the plot, and every column sits on
|
|
36
|
+
// the baseline: y + height is the full viewBox height.
|
|
37
|
+
for (const column of columns) {
|
|
38
|
+
expect(column.y + column.height).toBeCloseTo(180)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('keeps the unique-visitor mark inset within its own day', () => {
|
|
43
|
+
const [column] = buildAnalyticsColumns([day('2026-08-20', 10, 4)])
|
|
44
|
+
if (column == null) throw new Error('expected one column')
|
|
45
|
+
|
|
46
|
+
// Visitors can never exceed that day's views, so the inset mark is always
|
|
47
|
+
// shorter and narrower than the column it sits inside.
|
|
48
|
+
expect(column.insetHeight).toBeLessThan(column.height)
|
|
49
|
+
expect(column.insetWidth).toBeLessThan(column.width)
|
|
50
|
+
expect(column.insetX).toBeGreaterThan(column.x)
|
|
51
|
+
expect(column.insetX + column.insetWidth).toBeLessThan(column.x + column.width)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('survives an all-zero window without dividing by zero', () => {
|
|
55
|
+
const columns = buildAnalyticsColumns([day('2026-08-20', 0, 0), day('2026-08-21', 0, 0)])
|
|
56
|
+
expect(columns.map((column) => column.height)).toEqual([0, 0])
|
|
57
|
+
expect(columns.every((column) => Number.isFinite(column.x))).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns nothing for an empty window', () => {
|
|
61
|
+
expect(buildAnalyticsColumns([])).toEqual([])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('spans the full plot width and never overlaps neighbouring hit targets', () => {
|
|
65
|
+
const columns = buildAnalyticsColumns([
|
|
66
|
+
day('2026-08-20', 1, 1),
|
|
67
|
+
day('2026-08-21', 2, 1),
|
|
68
|
+
day('2026-08-22', 3, 2),
|
|
69
|
+
])
|
|
70
|
+
expect(columns[0]?.hitX).toBe(0)
|
|
71
|
+
const last = columns[columns.length - 1]
|
|
72
|
+
expect((last?.hitX ?? 0) + (last?.hitWidth ?? 0)).toBeCloseTo(900)
|
|
73
|
+
for (let index = 1; index < columns.length; index += 1) {
|
|
74
|
+
const previous = columns[index - 1]
|
|
75
|
+
expect(columns[index]?.hitX).toBeCloseTo((previous?.hitX ?? 0) + (previous?.hitWidth ?? 0))
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('analytics chart buckets', () => {
|
|
81
|
+
it('selects granularity explicitly from the reporting period and range size', () => {
|
|
82
|
+
expect(resolveAnalyticsChartGranularity(90, 90)).toBe('day')
|
|
83
|
+
expect(resolveAnalyticsChartGranularity('ytd', 90)).toBe('seven-day')
|
|
84
|
+
expect(resolveAnalyticsChartGranularity('all', 90)).toBe('day')
|
|
85
|
+
expect(resolveAnalyticsChartGranularity('all', 91)).toBe('seven-day')
|
|
86
|
+
expect(resolveAnalyticsChartGranularity('all', 733)).toBe('month')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('keeps day boundaries and width in daily buckets', () => {
|
|
90
|
+
expect(bucketAnalyticsTimeseries([day('2026-01-01', 2, 1)], 'day')).toEqual([
|
|
91
|
+
{
|
|
92
|
+
from: '2026-01-01',
|
|
93
|
+
to: '2026-01-01',
|
|
94
|
+
granularity: 'day',
|
|
95
|
+
dayCount: 1,
|
|
96
|
+
views: 2,
|
|
97
|
+
visitors: 1,
|
|
98
|
+
downloads: 0,
|
|
99
|
+
},
|
|
100
|
+
])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('sums daily rows into explicit seven-day buckets', () => {
|
|
104
|
+
const days = Array.from({ length: 8 }, (_, index) =>
|
|
105
|
+
day(`2026-01-${String(index + 1).padStart(2, '0')}`, 2, 1)
|
|
106
|
+
)
|
|
107
|
+
expect(bucketAnalyticsTimeseries(days, 'seven-day')).toEqual([
|
|
108
|
+
{
|
|
109
|
+
from: '2026-01-01',
|
|
110
|
+
to: '2026-01-07',
|
|
111
|
+
granularity: 'seven-day',
|
|
112
|
+
dayCount: 7,
|
|
113
|
+
views: 14,
|
|
114
|
+
visitors: 7,
|
|
115
|
+
downloads: 0,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
from: '2026-01-08',
|
|
119
|
+
to: '2026-01-08',
|
|
120
|
+
granularity: 'seven-day',
|
|
121
|
+
dayCount: 1,
|
|
122
|
+
views: 2,
|
|
123
|
+
visitors: 1,
|
|
124
|
+
downloads: 0,
|
|
125
|
+
},
|
|
126
|
+
])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('aligns month buckets to UTC calendar boundaries', () => {
|
|
130
|
+
expect(
|
|
131
|
+
bucketAnalyticsTimeseries(
|
|
132
|
+
[day('2026-01-31', 2, 1), day('2026-02-01', 3, 2), day('2026-02-02', 4, 2)],
|
|
133
|
+
'month'
|
|
134
|
+
)
|
|
135
|
+
).toEqual([
|
|
136
|
+
{
|
|
137
|
+
from: '2026-01-31',
|
|
138
|
+
to: '2026-01-31',
|
|
139
|
+
granularity: 'month',
|
|
140
|
+
dayCount: 1,
|
|
141
|
+
views: 2,
|
|
142
|
+
visitors: 1,
|
|
143
|
+
downloads: 0,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
from: '2026-02-01',
|
|
147
|
+
to: '2026-02-02',
|
|
148
|
+
granularity: 'month',
|
|
149
|
+
dayCount: 2,
|
|
150
|
+
views: 7,
|
|
151
|
+
visitors: 4,
|
|
152
|
+
downloads: 0,
|
|
153
|
+
},
|
|
154
|
+
])
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('shareWidth', () => {
|
|
159
|
+
it('scales a row against the largest row in its list', () => {
|
|
160
|
+
expect(shareWidth(50, 100)).toBe(50)
|
|
161
|
+
expect(shareWidth(100, 100)).toBe(100)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('keeps the smallest row visible rather than collapsing it', () => {
|
|
165
|
+
expect(shareWidth(1, 10_000)).toBe(3)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('returns no bar for absent or impossible values', () => {
|
|
169
|
+
expect(shareWidth(0, 100)).toBe(0)
|
|
170
|
+
expect(shareWidth(5, 0)).toBe(0)
|
|
171
|
+
expect(shareWidth(Number.NaN, 100)).toBe(0)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('formatShare', () => {
|
|
176
|
+
it('renders a locale-aware percentage of the whole', () => {
|
|
177
|
+
expect(formatShare(96, 1_200, 'en-US')).toBe('8%')
|
|
178
|
+
expect(formatShare(1, 3, 'en-US')).toBe('33.3%')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('reports zero rather than NaN when there is nothing to divide', () => {
|
|
182
|
+
expect(formatShare(0, 0, 'en-US')).toBe('0%')
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe('partialCoverageFrom', () => {
|
|
187
|
+
it('returns a retained boundary only when it truncates the report', () => {
|
|
188
|
+
expect(partialCoverageFrom('2025-01-01', '2026-05-26')).toBe('2026-05-26')
|
|
189
|
+
expect(partialCoverageFrom('2026-05-27', '2026-05-26')).toBeUndefined()
|
|
190
|
+
expect(partialCoverageFrom('2026-05-26', '2026-05-26')).toBeUndefined()
|
|
191
|
+
expect(partialCoverageFrom('2025-01-01', null)).toBeUndefined()
|
|
192
|
+
})
|
|
193
|
+
})
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) Infonomic Company Limited
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type React from 'react'
|
|
12
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
ANALYTICS_DASHBOARD_PERIODS,
|
|
16
|
+
ANALYTICS_OVERFLOW_KEY,
|
|
17
|
+
isAnalyticsDashboardPeriod,
|
|
18
|
+
} from '@byline/analytics/config'
|
|
19
|
+
import { ANALYTICS_IGNORE_STORAGE_KEY } from '@byline/analytics-agent'
|
|
20
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
21
|
+
import { Button, Card, Container, Section, Select } from '@byline/ui/react'
|
|
22
|
+
import cx from 'clsx'
|
|
23
|
+
|
|
24
|
+
import styles from './dashboard.module.css'
|
|
25
|
+
import { AnalyticsTimeseries, resolveAnalyticsChartGranularity } from './timeseries.js'
|
|
26
|
+
import type { AnalyticsDashboardData, AnalyticsDashboardPeriod } from '../types.js'
|
|
27
|
+
|
|
28
|
+
export interface AnalyticsDashboardProps {
|
|
29
|
+
data: AnalyticsDashboardData
|
|
30
|
+
period: AnalyticsDashboardPeriod
|
|
31
|
+
onPeriodChange(period: AnalyticsDashboardPeriod): void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function AnalyticsDashboard({
|
|
35
|
+
data,
|
|
36
|
+
period,
|
|
37
|
+
onPeriodChange,
|
|
38
|
+
}: AnalyticsDashboardProps): React.JSX.Element {
|
|
39
|
+
const { locale, t } = useTranslation('byline-admin')
|
|
40
|
+
const [excluded, setExcluded] = useState(false)
|
|
41
|
+
const numbers = useMemo(() => new Intl.NumberFormat(locale), [locale])
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
try {
|
|
45
|
+
setExcluded(localStorage.getItem(ANALYTICS_IGNORE_STORAGE_KEY) != null)
|
|
46
|
+
} catch {
|
|
47
|
+
setExcluded(false)
|
|
48
|
+
}
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
const toggleExclusion = () => {
|
|
52
|
+
try {
|
|
53
|
+
if (excluded) localStorage.removeItem(ANALYTICS_IGNORE_STORAGE_KEY)
|
|
54
|
+
else localStorage.setItem(ANALYTICS_IGNORE_STORAGE_KEY, '1')
|
|
55
|
+
setExcluded(!excluded)
|
|
56
|
+
} catch {
|
|
57
|
+
// A blocked storage surface leaves the current collection behavior unchanged.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const periodItems = ANALYTICS_DASHBOARD_PERIODS.map((value) => ({
|
|
62
|
+
value: String(value),
|
|
63
|
+
label:
|
|
64
|
+
typeof value === 'number'
|
|
65
|
+
? t('analytics.period.days', { count: value })
|
|
66
|
+
: t(`analytics.period.${value}`),
|
|
67
|
+
}))
|
|
68
|
+
|
|
69
|
+
const { views, visitors, downloads } = data.summary
|
|
70
|
+
const days = data.summary.timeseries.length
|
|
71
|
+
const chartGranularity = resolveAnalyticsChartGranularity(period, days)
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Section>
|
|
75
|
+
<Container>
|
|
76
|
+
<header className={cx('byline-analytics-header', styles.header)}>
|
|
77
|
+
<div>
|
|
78
|
+
<h1 className={cx('byline-analytics-title', styles.title)}>{t('analytics.title')}</h1>
|
|
79
|
+
<p className={cx('muted', 'byline-analytics-help', styles.help)}>
|
|
80
|
+
{t('analytics.dailyUniquesHelp')}
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
<div className={cx('byline-analytics-controls', styles.controls)}>
|
|
84
|
+
<Select<string>
|
|
85
|
+
id="analytics-period"
|
|
86
|
+
name="analytics-period"
|
|
87
|
+
aria-label={t('analytics.period.label')}
|
|
88
|
+
size="sm"
|
|
89
|
+
value={String(period)}
|
|
90
|
+
items={periodItems}
|
|
91
|
+
onValueChange={(value) => {
|
|
92
|
+
const next = value === 'ytd' || value === 'all' ? value : Number(value)
|
|
93
|
+
if (isAnalyticsDashboardPeriod(next)) onPeriodChange(next)
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
<Button
|
|
97
|
+
type="button"
|
|
98
|
+
size="sm"
|
|
99
|
+
aria-pressed={excluded}
|
|
100
|
+
// Local storage is origin-scoped, so this only governs public-page
|
|
101
|
+
// collection when the admin and the public site share an origin.
|
|
102
|
+
title={t('analytics.exclusion.help')}
|
|
103
|
+
onClick={toggleExclusion}
|
|
104
|
+
>
|
|
105
|
+
{excluded ? t('analytics.exclusion.include') : t('analytics.exclusion.exclude')}
|
|
106
|
+
</Button>
|
|
107
|
+
</div>
|
|
108
|
+
</header>
|
|
109
|
+
|
|
110
|
+
{/* Tinted ground, saturated ink, tracked label, tabular number — the
|
|
111
|
+
same tile grammar as the collection dashboard's status counts. */}
|
|
112
|
+
<div className={cx('byline-analytics-stats', styles.stats)}>
|
|
113
|
+
<StatTile
|
|
114
|
+
tone="views"
|
|
115
|
+
label={t('analytics.stats.views')}
|
|
116
|
+
value={numbers.format(views)}
|
|
117
|
+
foot={t('analytics.stats.perDay', {
|
|
118
|
+
count: days === 0 ? 0 : Math.round(views / days),
|
|
119
|
+
})}
|
|
120
|
+
/>
|
|
121
|
+
<StatTile
|
|
122
|
+
tone="visitors"
|
|
123
|
+
label={t('analytics.stats.dailyUniques')}
|
|
124
|
+
value={numbers.format(visitors)}
|
|
125
|
+
// The qualification rides under the figure it qualifies rather
|
|
126
|
+
// than sitting in help text several elements away.
|
|
127
|
+
foot={t('analytics.stats.sumOfDays', { count: days })}
|
|
128
|
+
/>
|
|
129
|
+
<StatTile
|
|
130
|
+
tone="downloads"
|
|
131
|
+
label={t('analytics.stats.downloads')}
|
|
132
|
+
value={numbers.format(downloads)}
|
|
133
|
+
foot={t('analytics.stats.shareOfViews', {
|
|
134
|
+
share: formatShare(downloads, views, locale),
|
|
135
|
+
})}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<Card className={cx('byline-analytics-chart-card', styles.chartCard)}>
|
|
140
|
+
<Card.Header>
|
|
141
|
+
<Card.Title>
|
|
142
|
+
{chartGranularity === 'day'
|
|
143
|
+
? t('analytics.chart.perDay')
|
|
144
|
+
: chartGranularity === 'seven-day'
|
|
145
|
+
? t('analytics.chart.perSevenDays')
|
|
146
|
+
: t('analytics.chart.perMonth')}
|
|
147
|
+
</Card.Title>
|
|
148
|
+
</Card.Header>
|
|
149
|
+
<Card.Content>
|
|
150
|
+
<AnalyticsTimeseries
|
|
151
|
+
days={data.summary.timeseries}
|
|
152
|
+
granularity={chartGranularity}
|
|
153
|
+
locale={locale}
|
|
154
|
+
/>
|
|
155
|
+
</Card.Content>
|
|
156
|
+
</Card>
|
|
157
|
+
|
|
158
|
+
{/* Top pages is the list people actually read, so it gets the wide
|
|
159
|
+
column; referrers and countries stack beside it. */}
|
|
160
|
+
<div className={cx('byline-analytics-lists', styles.lists)}>
|
|
161
|
+
<RankedList
|
|
162
|
+
title={t('analytics.sections.pages')}
|
|
163
|
+
caption={t('analytics.columns.viewsAndUniques')}
|
|
164
|
+
tone="views"
|
|
165
|
+
locale={locale}
|
|
166
|
+
rows={data.pages.rows.map(toPathRow)}
|
|
167
|
+
total={data.pages.total}
|
|
168
|
+
coverageFrom={partialCoverageFrom(data.range.from, data.coverage.pathsFrom)}
|
|
169
|
+
/>
|
|
170
|
+
<div className={styles.stack}>
|
|
171
|
+
<RankedList
|
|
172
|
+
title={t('analytics.sections.referrers')}
|
|
173
|
+
tone="visitors"
|
|
174
|
+
locale={locale}
|
|
175
|
+
total={data.referrers.total}
|
|
176
|
+
coverageFrom={partialCoverageFrom(data.range.from, data.coverage.referrersFrom)}
|
|
177
|
+
rows={data.referrers.rows.map((row) => ({
|
|
178
|
+
key: row.referrerHost,
|
|
179
|
+
label: row.referrerHost,
|
|
180
|
+
value: row.views,
|
|
181
|
+
visitors: row.visitors,
|
|
182
|
+
overflow: row.referrerHost === ANALYTICS_OVERFLOW_KEY,
|
|
183
|
+
}))}
|
|
184
|
+
/>
|
|
185
|
+
<RankedList
|
|
186
|
+
title={t('analytics.sections.countries')}
|
|
187
|
+
tone="visitors"
|
|
188
|
+
locale={locale}
|
|
189
|
+
rows={data.countries.map((row) => ({
|
|
190
|
+
key: row.country,
|
|
191
|
+
label: row.country,
|
|
192
|
+
value: row.views,
|
|
193
|
+
visitors: row.visitors,
|
|
194
|
+
overflow: false,
|
|
195
|
+
}))}
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<RankedList
|
|
201
|
+
title={t('analytics.sections.downloads')}
|
|
202
|
+
caption={t('analytics.columns.clicksAndUniques')}
|
|
203
|
+
tone="downloads"
|
|
204
|
+
locale={locale}
|
|
205
|
+
rows={data.downloads.rows.map(toPathRow)}
|
|
206
|
+
total={data.downloads.total}
|
|
207
|
+
coverageFrom={partialCoverageFrom(data.range.from, data.coverage.pathsFrom)}
|
|
208
|
+
/>
|
|
209
|
+
</Container>
|
|
210
|
+
</Section>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
type AnalyticsTone = 'views' | 'visitors' | 'downloads'
|
|
215
|
+
|
|
216
|
+
const TONE_TILE: Record<AnalyticsTone, string | undefined> = {
|
|
217
|
+
views: styles.toneViews,
|
|
218
|
+
visitors: styles.toneVisitors,
|
|
219
|
+
downloads: styles.toneDownloads,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const TONE_BAR: Record<AnalyticsTone, string | undefined> = {
|
|
223
|
+
views: styles.barViews,
|
|
224
|
+
visitors: styles.barVisitors,
|
|
225
|
+
downloads: styles.barDownloads,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function StatTile({
|
|
229
|
+
tone,
|
|
230
|
+
label,
|
|
231
|
+
value,
|
|
232
|
+
foot,
|
|
233
|
+
}: {
|
|
234
|
+
tone: AnalyticsTone
|
|
235
|
+
label: string
|
|
236
|
+
value: string
|
|
237
|
+
foot: string
|
|
238
|
+
}): React.JSX.Element {
|
|
239
|
+
return (
|
|
240
|
+
<div className={cx('byline-analytics-stat', styles.stat, TONE_TILE[tone])}>
|
|
241
|
+
<span className={cx('byline-analytics-stat-label', styles.statLabel)}>{label}</span>
|
|
242
|
+
<span className={cx('byline-analytics-stat-value', styles.statValue)}>{value}</span>
|
|
243
|
+
<span className={cx('byline-analytics-stat-foot', styles.statFoot)}>{foot}</span>
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface RankedRow {
|
|
249
|
+
key: string
|
|
250
|
+
label: string
|
|
251
|
+
value: number
|
|
252
|
+
visitors: number
|
|
253
|
+
overflow: boolean
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function toPathRow(row: { path: string; views: number; visitors: number }): RankedRow {
|
|
257
|
+
return {
|
|
258
|
+
key: row.path,
|
|
259
|
+
label: row.path,
|
|
260
|
+
value: row.views,
|
|
261
|
+
visitors: row.visitors,
|
|
262
|
+
overflow: row.path === ANALYTICS_OVERFLOW_KEY,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function RankedList({
|
|
267
|
+
title,
|
|
268
|
+
caption,
|
|
269
|
+
rows,
|
|
270
|
+
tone,
|
|
271
|
+
locale,
|
|
272
|
+
total,
|
|
273
|
+
coverageFrom,
|
|
274
|
+
}: {
|
|
275
|
+
title: string
|
|
276
|
+
caption?: string
|
|
277
|
+
rows: RankedRow[]
|
|
278
|
+
tone: AnalyticsTone
|
|
279
|
+
locale: string
|
|
280
|
+
/** Distinct keys in the period; omit for lists that are never truncated. */
|
|
281
|
+
total?: number
|
|
282
|
+
/** First complete day when the selected report begins before retained rows. */
|
|
283
|
+
coverageFrom?: string
|
|
284
|
+
}): React.JSX.Element {
|
|
285
|
+
const { t } = useTranslation('byline-admin')
|
|
286
|
+
const numbers = useMemo(() => new Intl.NumberFormat(locale), [locale])
|
|
287
|
+
const ceiling = Math.max(1, ...rows.map((row) => row.value))
|
|
288
|
+
// Say so when the list is a top-N slice. Without this the card presents a
|
|
289
|
+
// truncated ranking as though it were the whole set.
|
|
290
|
+
const truncated = total != null && total > rows.length
|
|
291
|
+
const coverageDate = useMemo(
|
|
292
|
+
() =>
|
|
293
|
+
new Intl.DateTimeFormat(locale, {
|
|
294
|
+
dateStyle: 'medium',
|
|
295
|
+
timeZone: 'UTC',
|
|
296
|
+
}),
|
|
297
|
+
[locale]
|
|
298
|
+
)
|
|
299
|
+
const description = [
|
|
300
|
+
truncated ? t('analytics.topOf', { shown: rows.length, total }) : caption,
|
|
301
|
+
coverageFrom == null
|
|
302
|
+
? undefined
|
|
303
|
+
: t('analytics.coverage.since', {
|
|
304
|
+
date: coverageDate.format(new Date(`${coverageFrom}T00:00:00.000Z`)),
|
|
305
|
+
}),
|
|
306
|
+
]
|
|
307
|
+
.filter((value): value is string => value != null)
|
|
308
|
+
.join(' · ')
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<Card className={cx('byline-analytics-list', styles.list)}>
|
|
312
|
+
<Card.Header>
|
|
313
|
+
<Card.Title>{title}</Card.Title>
|
|
314
|
+
{description.length > 0 && <Card.Description>{description}</Card.Description>}
|
|
315
|
+
</Card.Header>
|
|
316
|
+
<Card.Content>
|
|
317
|
+
{rows.length === 0 ? (
|
|
318
|
+
<p className="muted">{t('analytics.empty')}</p>
|
|
319
|
+
) : (
|
|
320
|
+
<ol className={cx('byline-analytics-ranking', styles.ranking)}>
|
|
321
|
+
{rows.map((row) => (
|
|
322
|
+
<li
|
|
323
|
+
key={row.key}
|
|
324
|
+
className={cx(
|
|
325
|
+
'byline-analytics-ranking-row',
|
|
326
|
+
styles.rankingRow,
|
|
327
|
+
TONE_BAR[tone],
|
|
328
|
+
row.overflow && styles.rankingOverflow
|
|
329
|
+
)}
|
|
330
|
+
// The share bar sits behind the row so the label and its
|
|
331
|
+
// magnitude occupy one line and are read together.
|
|
332
|
+
style={
|
|
333
|
+
{
|
|
334
|
+
'--byline-analytics-share': `${shareWidth(row.value, ceiling)}%`,
|
|
335
|
+
} as React.CSSProperties
|
|
336
|
+
}
|
|
337
|
+
>
|
|
338
|
+
<span className={styles.rankingLabel} title={row.key}>
|
|
339
|
+
{/* `__other__` is a reserved aggregate, not a page anyone
|
|
340
|
+
visited — never render it as though it were a real path. */}
|
|
341
|
+
{row.overflow ? t('analytics.overflow') : row.label}
|
|
342
|
+
</span>
|
|
343
|
+
<span className={styles.rankingValue}>{numbers.format(row.value)}</span>
|
|
344
|
+
<span className={styles.rankingVisitors}>{numbers.format(row.visitors)}</span>
|
|
345
|
+
</li>
|
|
346
|
+
))}
|
|
347
|
+
</ol>
|
|
348
|
+
)}
|
|
349
|
+
</Card.Content>
|
|
350
|
+
</Card>
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Never collapse the bar entirely: a visible sliver still encodes "smallest". */
|
|
355
|
+
export function shareWidth(value: number, ceiling: number): number {
|
|
356
|
+
if (!Number.isFinite(value) || value <= 0 || ceiling <= 0) return 0
|
|
357
|
+
return Math.max(3, Math.min(100, (value / ceiling) * 100))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function formatShare(part: number, whole: number, locale: string): string {
|
|
361
|
+
const percent = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 1 })
|
|
362
|
+
return percent.format(whole <= 0 ? 0 : part / whole)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Return the retained boundary only when it truncates the selected range. */
|
|
366
|
+
export function partialCoverageFrom(
|
|
367
|
+
rangeFrom: string,
|
|
368
|
+
coverageFrom: string | null
|
|
369
|
+
): string | undefined {
|
|
370
|
+
return coverageFrom != null && coverageFrom > rangeFrom ? coverageFrom : undefined
|
|
371
|
+
}
|