@byline/admin 4.14.1 → 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/forms/document-actions.d.ts +16 -1
- package/dist/forms/document-actions.js +43 -1
- package/dist/forms/form-renderer.d.ts +6 -1
- package/dist/forms/form-renderer.js +39 -36
- package/dist/forms/form-renderer.module.js +2 -0
- package/dist/forms/form-renderer_module.css +7 -1
- package/dist/forms/form-status-display.d.ts +10 -1
- package/dist/forms/form-status-display.js +4 -2
- package/dist/forms/scheduled-publication-control.d.ts +56 -0
- package/dist/forms/scheduled-publication-control.js +376 -0
- package/dist/forms/scheduled-publication-control.module.js +25 -0
- package/dist/forms/scheduled-publication-control_module.css +95 -0
- package/dist/forms/scheduled-publication-state.d.ts +83 -0
- package/dist/forms/scheduled-publication-state.js +41 -0
- package/dist/forms/scheduled-publication-state.test.node.d.ts +8 -0
- package/dist/forms/scheduled-publication-time.d.ts +57 -0
- package/dist/forms/scheduled-publication-time.js +113 -0
- package/dist/forms/scheduled-publication-time.test.node.d.ts +8 -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/dist/react.d.ts +1 -0
- package/dist/react.js +1 -0
- package/package.json +23 -7
- package/src/abilities.ts +2 -0
- package/src/forms/document-actions.tsx +73 -0
- package/src/forms/form-renderer.module.css +15 -0
- package/src/forms/form-renderer.tsx +59 -64
- package/src/forms/form-status-display.tsx +12 -0
- package/src/forms/path-widget.test.tsx +20 -8
- package/src/forms/scheduled-publication-control.module.css +149 -0
- package/src/forms/scheduled-publication-control.tsx +590 -0
- package/src/forms/scheduled-publication-datepicker.test.tsx +89 -0
- package/src/forms/scheduled-publication-state.test.node.ts +190 -0
- package/src/forms/scheduled-publication-state.ts +146 -0
- package/src/forms/scheduled-publication-time.test.node.ts +86 -0
- package/src/forms/scheduled-publication-time.ts +218 -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
- package/src/react.ts +3 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnalyticsDashboard — the installation-level analytics page.
|
|
3
|
+
*
|
|
4
|
+
* Override handles:
|
|
5
|
+
* .byline-analytics-header — title block and period/exclusion controls
|
|
6
|
+
* .byline-analytics-title — page heading
|
|
7
|
+
* .byline-analytics-help — the daily-uniques qualification
|
|
8
|
+
* .byline-analytics-controls — period picker + exclusion toggle
|
|
9
|
+
* .byline-analytics-stats — the three metric tiles
|
|
10
|
+
* .byline-analytics-stat — one tile
|
|
11
|
+
* .byline-analytics-stat-label — tracked uppercase metric name
|
|
12
|
+
* .byline-analytics-stat-value — the figure
|
|
13
|
+
* .byline-analytics-stat-foot — the figure's qualification
|
|
14
|
+
* .byline-analytics-chart-card — card wrapping the timeseries
|
|
15
|
+
* .byline-analytics-lists — top-pages / referrers+countries band
|
|
16
|
+
* .byline-analytics-list — one ranked-list card
|
|
17
|
+
* .byline-analytics-ranking — the ordered list
|
|
18
|
+
* .byline-analytics-ranking-row — one row, carrying its share bar
|
|
19
|
+
*
|
|
20
|
+
* The tile hues are the collection dashboard's status-tile values
|
|
21
|
+
* (`byline-dashboard-stat-tile`), so the two surfaces read as one system.
|
|
22
|
+
* Amber is deliberately unused here: it already means "draft".
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
.header,
|
|
26
|
+
:global(.byline-analytics-header) {
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: flex-end;
|
|
29
|
+
justify-content: space-between;
|
|
30
|
+
gap: 1rem 1.5rem;
|
|
31
|
+
flex-wrap: wrap;
|
|
32
|
+
margin-bottom: 1.25rem;
|
|
33
|
+
padding-bottom: 1rem;
|
|
34
|
+
border-bottom: var(--border-width-thin) var(--border-style-solid) var(--gray-200);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.title,
|
|
38
|
+
:global(.byline-analytics-title) {
|
|
39
|
+
margin: 0;
|
|
40
|
+
font-size: 1.875rem;
|
|
41
|
+
font-weight: 650;
|
|
42
|
+
letter-spacing: -0.02em;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.help,
|
|
46
|
+
:global(.byline-analytics-help) {
|
|
47
|
+
max-width: 48ch;
|
|
48
|
+
margin: 0.35rem 0 0;
|
|
49
|
+
font-size: 0.8125rem;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* `Card.Title` defaults to 1.8rem bold — larger than this page's own heading,
|
|
53
|
+
which inverted the hierarchy. Bring the section titles below the h1 while
|
|
54
|
+
keeping them clearly headings. Scoped to the analytics cards so no other
|
|
55
|
+
Card consumer shifts. */
|
|
56
|
+
.chartCard :global(.byline-card-title),
|
|
57
|
+
.list :global(.byline-card-title) {
|
|
58
|
+
font-size: 1.25rem;
|
|
59
|
+
font-weight: 600;
|
|
60
|
+
letter-spacing: normal;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.chartCard :global(.byline-card-description),
|
|
64
|
+
.list :global(.byline-card-description) {
|
|
65
|
+
font-size: 0.75rem;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.controls,
|
|
69
|
+
:global(.byline-analytics-controls) {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 0.75rem;
|
|
73
|
+
flex-wrap: wrap;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* ── Metric tiles ──────────────────────────────────────────────────── */
|
|
77
|
+
|
|
78
|
+
.stats,
|
|
79
|
+
:global(.byline-analytics-stats) {
|
|
80
|
+
display: grid;
|
|
81
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
82
|
+
gap: 0.75rem;
|
|
83
|
+
margin-bottom: 0.75rem;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.stat,
|
|
87
|
+
:global(.byline-analytics-stat) {
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-direction: column;
|
|
90
|
+
gap: 0.5rem;
|
|
91
|
+
min-width: 0;
|
|
92
|
+
padding: 1rem 1.125rem 1.125rem;
|
|
93
|
+
border-radius: 0.375rem;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.statLabel,
|
|
97
|
+
:global(.byline-analytics-stat-label) {
|
|
98
|
+
font-size: 0.625rem;
|
|
99
|
+
font-weight: 600;
|
|
100
|
+
text-transform: uppercase;
|
|
101
|
+
letter-spacing: 0.1em;
|
|
102
|
+
line-height: 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.statValue,
|
|
106
|
+
:global(.byline-analytics-stat-value) {
|
|
107
|
+
font-size: clamp(1.75rem, 4.5vw, 2.5rem);
|
|
108
|
+
font-weight: 700;
|
|
109
|
+
font-variant-numeric: tabular-nums;
|
|
110
|
+
line-height: 1;
|
|
111
|
+
letter-spacing: -0.02em;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.statFoot,
|
|
115
|
+
:global(.byline-analytics-stat-foot) {
|
|
116
|
+
font-size: 0.6875rem;
|
|
117
|
+
font-variant-numeric: tabular-nums;
|
|
118
|
+
color: var(--gray-600);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.toneViews {
|
|
122
|
+
background-color: #f0f9ff;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.toneViews .statLabel,
|
|
126
|
+
.toneViews .statValue {
|
|
127
|
+
color: #0369a1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.toneVisitors {
|
|
131
|
+
background-color: #f5f3ff;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.toneVisitors .statLabel,
|
|
135
|
+
.toneVisitors .statValue {
|
|
136
|
+
color: #6d28d9;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.toneDownloads {
|
|
140
|
+
background-color: #ecfdf5;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.toneDownloads .statLabel,
|
|
144
|
+
.toneDownloads .statValue {
|
|
145
|
+
color: #047857;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
:is([data-theme="dark"], :global(.dark)) .statFoot,
|
|
149
|
+
:is([data-theme="dark"], :global(.dark)) :global(.byline-analytics-stat-foot) {
|
|
150
|
+
color: var(--gray-400);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
:is([data-theme="dark"], :global(.dark)) .toneViews {
|
|
154
|
+
background-color: rgba(12, 74, 110, 0.28);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
:is([data-theme="dark"], :global(.dark)) .toneViews .statLabel,
|
|
158
|
+
:is([data-theme="dark"], :global(.dark)) .toneViews .statValue {
|
|
159
|
+
color: #7dd3fc;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
:is([data-theme="dark"], :global(.dark)) .toneVisitors {
|
|
163
|
+
background-color: rgba(76, 29, 149, 0.3);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
:is([data-theme="dark"], :global(.dark)) .toneVisitors .statLabel,
|
|
167
|
+
:is([data-theme="dark"], :global(.dark)) .toneVisitors .statValue {
|
|
168
|
+
color: #c4b5fd;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
:is([data-theme="dark"], :global(.dark)) .toneDownloads {
|
|
172
|
+
background-color: rgba(6, 78, 59, 0.28);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
:is([data-theme="dark"], :global(.dark)) .toneDownloads .statLabel,
|
|
176
|
+
:is([data-theme="dark"], :global(.dark)) .toneDownloads .statValue {
|
|
177
|
+
color: #6ee7b7;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ── Lists ─────────────────────────────────────────────────────────── */
|
|
181
|
+
|
|
182
|
+
.chartCard,
|
|
183
|
+
:global(.byline-analytics-chart-card) {
|
|
184
|
+
margin-bottom: 0.75rem;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* Top pages is the list people actually read, so it takes the wide track. */
|
|
188
|
+
.lists,
|
|
189
|
+
:global(.byline-analytics-lists) {
|
|
190
|
+
display: grid;
|
|
191
|
+
grid-template-columns: 1.55fr 1fr;
|
|
192
|
+
align-items: start;
|
|
193
|
+
gap: 0.75rem;
|
|
194
|
+
margin-bottom: 0.75rem;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.stack {
|
|
198
|
+
display: flex;
|
|
199
|
+
flex-direction: column;
|
|
200
|
+
gap: 0.75rem;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.ranking,
|
|
204
|
+
:global(.byline-analytics-ranking) {
|
|
205
|
+
display: flex;
|
|
206
|
+
flex-direction: column;
|
|
207
|
+
gap: 1px;
|
|
208
|
+
padding: 0;
|
|
209
|
+
margin: 0;
|
|
210
|
+
list-style: none;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.rankingRow,
|
|
214
|
+
:global(.byline-analytics-ranking-row) {
|
|
215
|
+
position: relative;
|
|
216
|
+
display: grid;
|
|
217
|
+
grid-template-columns: minmax(0, 1fr) auto auto;
|
|
218
|
+
align-items: center;
|
|
219
|
+
gap: 0.75rem;
|
|
220
|
+
padding: 0.45rem 0.625rem;
|
|
221
|
+
border-radius: 0.25rem;
|
|
222
|
+
/* The row paints its own stacking context so the share bar can sit behind
|
|
223
|
+
the text without escaping to the card background. */
|
|
224
|
+
isolation: isolate;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.rankingRow::before {
|
|
228
|
+
content: "";
|
|
229
|
+
position: absolute;
|
|
230
|
+
inset: 0 auto 0 0;
|
|
231
|
+
z-index: -1;
|
|
232
|
+
width: var(--byline-analytics-share, 0%);
|
|
233
|
+
border-radius: 0.25rem;
|
|
234
|
+
background-color: var(--byline-analytics-bar, transparent);
|
|
235
|
+
opacity: 0.16;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.barViews {
|
|
239
|
+
--byline-analytics-bar: #38bdf8;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.barVisitors {
|
|
243
|
+
--byline-analytics-bar: #7c3aed;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.barDownloads {
|
|
247
|
+
--byline-analytics-bar: #10b981;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* The reserved overflow bucket is an aggregate, not a page — a dashed rule
|
|
251
|
+
and muted label keep it from reading as traffic to a real path. */
|
|
252
|
+
.rankingOverflow {
|
|
253
|
+
border: var(--border-width-thin) dashed var(--gray-300);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.rankingOverflow::before {
|
|
257
|
+
opacity: 0.07;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.rankingOverflow .rankingLabel {
|
|
261
|
+
font-style: italic;
|
|
262
|
+
color: var(--gray-600);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.rankingLabel {
|
|
266
|
+
overflow: hidden;
|
|
267
|
+
min-width: 0;
|
|
268
|
+
font-size: 0.8125rem;
|
|
269
|
+
text-overflow: ellipsis;
|
|
270
|
+
white-space: nowrap;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.rankingValue {
|
|
274
|
+
font-size: 0.8125rem;
|
|
275
|
+
font-weight: 600;
|
|
276
|
+
font-variant-numeric: tabular-nums;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.rankingVisitors {
|
|
280
|
+
min-width: 3ch;
|
|
281
|
+
font-size: 0.6875rem;
|
|
282
|
+
font-variant-numeric: tabular-nums;
|
|
283
|
+
text-align: end;
|
|
284
|
+
color: var(--gray-500);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
:is([data-theme="dark"], :global(.dark)) .header,
|
|
288
|
+
:is([data-theme="dark"], :global(.dark)) :global(.byline-analytics-header) {
|
|
289
|
+
border-bottom-color: var(--gray-700);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
:is([data-theme="dark"], :global(.dark)) .rankingVisitors {
|
|
293
|
+
color: var(--gray-400);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
:is([data-theme="dark"], :global(.dark)) .rankingOverflow {
|
|
297
|
+
border-color: var(--gray-600);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
:is([data-theme="dark"], :global(.dark)) .rankingOverflow .rankingLabel {
|
|
301
|
+
color: var(--gray-400);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
:is([data-theme="dark"], :global(.dark)) .barVisitors {
|
|
305
|
+
--byline-analytics-bar: #a78bfa;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
:is([data-theme="dark"], :global(.dark)) .barDownloads {
|
|
309
|
+
--byline-analytics-bar: #34d399;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
@media (max-width: 900px) {
|
|
313
|
+
.lists,
|
|
314
|
+
:global(.byline-analytics-lists) {
|
|
315
|
+
grid-template-columns: 1fr;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
@media (max-width: 720px) {
|
|
320
|
+
.header,
|
|
321
|
+
:global(.byline-analytics-header) {
|
|
322
|
+
align-items: stretch;
|
|
323
|
+
flex-direction: column;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.stats,
|
|
327
|
+
:global(.byline-analytics-stats) {
|
|
328
|
+
grid-template-columns: 1fr;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -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
|
+
})
|