@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.
Files changed (62) hide show
  1. package/dist/abilities.js +2 -0
  2. package/dist/forms/document-actions.d.ts +16 -1
  3. package/dist/forms/document-actions.js +43 -1
  4. package/dist/forms/form-renderer.d.ts +6 -1
  5. package/dist/forms/form-renderer.js +39 -36
  6. package/dist/forms/form-renderer.module.js +2 -0
  7. package/dist/forms/form-renderer_module.css +7 -1
  8. package/dist/forms/form-status-display.d.ts +10 -1
  9. package/dist/forms/form-status-display.js +4 -2
  10. package/dist/forms/scheduled-publication-control.d.ts +56 -0
  11. package/dist/forms/scheduled-publication-control.js +376 -0
  12. package/dist/forms/scheduled-publication-control.module.js +25 -0
  13. package/dist/forms/scheduled-publication-control_module.css +95 -0
  14. package/dist/forms/scheduled-publication-state.d.ts +83 -0
  15. package/dist/forms/scheduled-publication-state.js +41 -0
  16. package/dist/forms/scheduled-publication-state.test.node.d.ts +8 -0
  17. package/dist/forms/scheduled-publication-time.d.ts +57 -0
  18. package/dist/forms/scheduled-publication-time.js +113 -0
  19. package/dist/forms/scheduled-publication-time.test.node.d.ts +8 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/modules/analytics/abilities.d.ts +14 -0
  23. package/dist/modules/analytics/abilities.js +21 -0
  24. package/dist/modules/analytics/components/dashboard.d.ts +20 -0
  25. package/dist/modules/analytics/components/dashboard.js +308 -0
  26. package/dist/modules/analytics/components/dashboard.module.js +29 -0
  27. package/dist/modules/analytics/components/dashboard.test.node.d.ts +8 -0
  28. package/dist/modules/analytics/components/dashboard_module.css +268 -0
  29. package/dist/modules/analytics/components/timeseries.d.ts +59 -0
  30. package/dist/modules/analytics/components/timeseries.js +253 -0
  31. package/dist/modules/analytics/components/timeseries.module.js +17 -0
  32. package/dist/modules/analytics/components/timeseries_module.css +126 -0
  33. package/dist/modules/analytics/index.d.ts +9 -0
  34. package/dist/modules/analytics/index.js +1 -0
  35. package/dist/modules/analytics/types.d.ts +20 -0
  36. package/dist/modules/analytics/types.js +1 -0
  37. package/dist/react.d.ts +1 -0
  38. package/dist/react.js +1 -0
  39. package/package.json +23 -7
  40. package/src/abilities.ts +2 -0
  41. package/src/forms/document-actions.tsx +73 -0
  42. package/src/forms/form-renderer.module.css +15 -0
  43. package/src/forms/form-renderer.tsx +59 -64
  44. package/src/forms/form-status-display.tsx +12 -0
  45. package/src/forms/path-widget.test.tsx +20 -8
  46. package/src/forms/scheduled-publication-control.module.css +149 -0
  47. package/src/forms/scheduled-publication-control.tsx +590 -0
  48. package/src/forms/scheduled-publication-datepicker.test.tsx +89 -0
  49. package/src/forms/scheduled-publication-state.test.node.ts +190 -0
  50. package/src/forms/scheduled-publication-state.ts +146 -0
  51. package/src/forms/scheduled-publication-time.test.node.ts +86 -0
  52. package/src/forms/scheduled-publication-time.ts +218 -0
  53. package/src/index.ts +1 -0
  54. package/src/modules/analytics/abilities.ts +33 -0
  55. package/src/modules/analytics/components/dashboard.module.css +330 -0
  56. package/src/modules/analytics/components/dashboard.test.node.ts +193 -0
  57. package/src/modules/analytics/components/dashboard.tsx +371 -0
  58. package/src/modules/analytics/components/timeseries.module.css +151 -0
  59. package/src/modules/analytics/components/timeseries.tsx +332 -0
  60. package/src/modules/analytics/index.ts +14 -0
  61. package/src/modules/analytics/types.ts +31 -0
  62. package/src/react.ts +3 -0
@@ -0,0 +1,332 @@
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
+ /**
12
+ * The analytics timeseries — one column per explicit UTC reporting bucket.
13
+ *
14
+ * A column rather than a continuous line is a claim about the data, not a
15
+ * style: the rows behind this chart are sums of daily aggregates and visitor
16
+ * hashes rotate at UTC midnight, so nothing is measured between two points.
17
+ * The dashboard selects the granularity and passes it into this renderer;
18
+ * the chart never guesses from the number of rows.
19
+ */
20
+ import type React from 'react'
21
+ import { useMemo, useRef, useState } from 'react'
22
+
23
+ import type { AnalyticsSummaryDay } from '@byline/analytics'
24
+ import type { AnalyticsDashboardPeriod } from '@byline/analytics/config'
25
+ import { useTranslation } from '@byline/i18n/react'
26
+ import cx from 'clsx'
27
+
28
+ import styles from './timeseries.module.css'
29
+
30
+ /** Drawn in a fixed user-space box and stretched to the container width. */
31
+ const VIEWBOX_WIDTH = 900
32
+ const VIEWBOX_HEIGHT = 180
33
+ const COLUMN_GAP_RATIO = 0.38
34
+ const MAX_COLUMN_WIDTH = 26
35
+
36
+ export type AnalyticsChartGranularity = 'day' | 'seven-day' | 'month'
37
+
38
+ export interface AnalyticsChartBucket {
39
+ from: string
40
+ to: string
41
+ granularity: AnalyticsChartGranularity
42
+ /** Actual number of daily rows, including partial first or last buckets. */
43
+ dayCount: number
44
+ views: number
45
+ /** Sum of the bucket's daily-unique visitor values. */
46
+ visitors: number
47
+ downloads: number
48
+ }
49
+
50
+ export interface AnalyticsColumn {
51
+ index: number
52
+ /** Full-height mark: that bucket's page views. */
53
+ x: number
54
+ width: number
55
+ y: number
56
+ height: number
57
+ /** Inset mark: summed daily uniques, always no greater than views. */
58
+ insetX: number
59
+ insetWidth: number
60
+ insetY: number
61
+ insetHeight: number
62
+ /** Full-height transparent target, so narrow columns stay easy to hit. */
63
+ hitX: number
64
+ hitWidth: number
65
+ }
66
+
67
+ /** Resolve chart density outside the renderer so the chosen width is explicit. */
68
+ export function resolveAnalyticsChartGranularity(
69
+ period: AnalyticsDashboardPeriod,
70
+ dayCount: number
71
+ ): AnalyticsChartGranularity {
72
+ if (typeof period === 'number' || (period === 'all' && dayCount <= 90)) return 'day'
73
+ if (period === 'ytd' || dayCount <= 732) return 'seven-day'
74
+ return 'month'
75
+ }
76
+
77
+ /** Combine complete daily query rows using the caller-selected granularity. */
78
+ export function bucketAnalyticsTimeseries(
79
+ days: readonly AnalyticsSummaryDay[],
80
+ granularity: AnalyticsChartGranularity
81
+ ): readonly AnalyticsChartBucket[] {
82
+ if (granularity === 'day') {
83
+ return days.map((day) => ({
84
+ from: day.day,
85
+ to: day.day,
86
+ granularity,
87
+ dayCount: 1,
88
+ views: day.views,
89
+ visitors: day.visitors,
90
+ downloads: day.downloads,
91
+ }))
92
+ }
93
+ if (granularity === 'seven-day') return bucketBySevenDays(days)
94
+ return bucketByUtcMonth(days)
95
+ }
96
+
97
+ /** Project reporting buckets into column geometry. */
98
+ export function buildAnalyticsColumns(
99
+ buckets: readonly Pick<AnalyticsChartBucket, 'views' | 'visitors'>[]
100
+ ): readonly AnalyticsColumn[] {
101
+ if (buckets.length === 0) return []
102
+ const ceiling = Math.max(1, ...buckets.map((bucket) => bucket.views))
103
+ const step = VIEWBOX_WIDTH / buckets.length
104
+ const width = Math.max(2, Math.min(step * (1 - COLUMN_GAP_RATIO), MAX_COLUMN_WIDTH))
105
+
106
+ return buckets.map((bucket, index) => {
107
+ const centre = index * step + step / 2
108
+ const height = (bucket.views / ceiling) * VIEWBOX_HEIGHT
109
+ const insetHeight = (bucket.visitors / ceiling) * VIEWBOX_HEIGHT
110
+ const insetWidth = Math.max(1, width / 2)
111
+ return {
112
+ index,
113
+ x: centre - width / 2,
114
+ width,
115
+ y: VIEWBOX_HEIGHT - height,
116
+ height,
117
+ insetX: centre - insetWidth / 2,
118
+ insetWidth,
119
+ insetY: VIEWBOX_HEIGHT - insetHeight,
120
+ insetHeight,
121
+ hitX: index * step,
122
+ hitWidth: step,
123
+ }
124
+ })
125
+ }
126
+
127
+ export interface AnalyticsTimeseriesProps {
128
+ days: readonly AnalyticsSummaryDay[]
129
+ granularity: AnalyticsChartGranularity
130
+ locale: string
131
+ }
132
+
133
+ export function AnalyticsTimeseries({
134
+ days,
135
+ granularity,
136
+ locale,
137
+ }: AnalyticsTimeseriesProps): React.JSX.Element {
138
+ const { t } = useTranslation('byline-admin')
139
+ const [hovered, setHovered] = useState<number | null>(null)
140
+ const plot = useRef<SVGSVGElement | null>(null)
141
+
142
+ const buckets = useMemo(() => bucketAnalyticsTimeseries(days, granularity), [days, granularity])
143
+ const columns = useMemo(() => buildAnalyticsColumns(buckets), [buckets])
144
+ const numbers = useMemo(() => new Intl.NumberFormat(locale), [locale])
145
+ // Stored days are UTC calendar days. Formatting in the viewer's zone would
146
+ // shift the labels by up to one day.
147
+ const dayLabel = useMemo(
148
+ () => new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', timeZone: 'UTC' }),
149
+ [locale]
150
+ )
151
+
152
+ const formatDay = (day: string) => dayLabel.format(new Date(`${day}T00:00:00.000Z`))
153
+ const formatBucket = (bucket: AnalyticsChartBucket) =>
154
+ bucket.dayCount === 1
155
+ ? formatDay(bucket.from)
156
+ : `${formatDay(bucket.from)} – ${formatDay(bucket.to)}`
157
+ const visitorLabel = (bucket: AnalyticsChartBucket) =>
158
+ bucket.granularity === 'day'
159
+ ? t('analytics.stats.dailyUniques')
160
+ : t('analytics.stats.summedDailyUniques')
161
+ const active = hovered == null ? undefined : buckets[hovered]
162
+ const readBucket = (bucket: AnalyticsChartBucket) =>
163
+ [
164
+ formatBucket(bucket),
165
+ `${t('analytics.stats.views')} ${numbers.format(bucket.views)}`,
166
+ `${visitorLabel(bucket)} ${numbers.format(bucket.visitors)}`,
167
+ `${t('analytics.stats.downloads')} ${numbers.format(bucket.downloads)}`,
168
+ ].join(', ')
169
+
170
+ // One handler on the plot rather than one per column keeps every reporting
171
+ // range to a single pointer listener and one keyboard tab stop.
172
+ const trackPointer = (event: React.PointerEvent<HTMLDivElement>) => {
173
+ if (buckets.length === 0 || plot.current == null) return
174
+ const bounds = plot.current.getBoundingClientRect()
175
+ if (bounds.width === 0) return
176
+ const ratio = (event.clientX - bounds.left) / bounds.width
177
+ setHovered(Math.min(buckets.length - 1, Math.max(0, Math.floor(ratio * buckets.length))))
178
+ }
179
+
180
+ const trackKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
181
+ if (buckets.length === 0) return
182
+ const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
183
+ if (step === 0 && event.key !== 'Home' && event.key !== 'End' && event.key !== 'Escape') return
184
+ event.preventDefault()
185
+ if (event.key === 'Escape') return setHovered(null)
186
+ if (event.key === 'Home') return setHovered(0)
187
+ if (event.key === 'End') return setHovered(buckets.length - 1)
188
+ setHovered((current) => {
189
+ const next = (current ?? (step > 0 ? -1 : buckets.length)) + step
190
+ return Math.min(buckets.length - 1, Math.max(0, next))
191
+ })
192
+ }
193
+
194
+ return (
195
+ <div
196
+ className={cx('byline-analytics-timeseries', styles.root)}
197
+ role="slider"
198
+ tabIndex={0}
199
+ aria-label={t('analytics.chart.views')}
200
+ aria-valuemin={0}
201
+ aria-valuemax={Math.max(0, buckets.length - 1)}
202
+ aria-valuenow={hovered ?? 0}
203
+ aria-valuetext={active == null ? t('analytics.chart.hint') : readBucket(active)}
204
+ onPointerMove={trackPointer}
205
+ onPointerLeave={() => setHovered(null)}
206
+ onKeyDown={trackKey}
207
+ onBlur={() => setHovered(null)}
208
+ >
209
+ <svg
210
+ ref={plot}
211
+ className={cx('byline-analytics-chart', styles.chart)}
212
+ viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
213
+ preserveAspectRatio="none"
214
+ aria-hidden="true"
215
+ >
216
+ <title>{t('analytics.chart.views')}</title>
217
+ {[0.5, 1].map((fraction) => (
218
+ <line
219
+ key={fraction}
220
+ className={styles.gridline}
221
+ x1={0}
222
+ x2={VIEWBOX_WIDTH}
223
+ y1={VIEWBOX_HEIGHT - VIEWBOX_HEIGHT * fraction}
224
+ y2={VIEWBOX_HEIGHT - VIEWBOX_HEIGHT * fraction}
225
+ vectorEffect="non-scaling-stroke"
226
+ />
227
+ ))}
228
+ {columns.map((column) => (
229
+ <g
230
+ key={buckets[column.index]?.from}
231
+ className={cx(styles.column, hovered === column.index && styles.columnActive)}
232
+ >
233
+ <rect
234
+ className={styles.views}
235
+ x={column.x}
236
+ width={column.width}
237
+ y={column.y}
238
+ height={column.height}
239
+ />
240
+ <rect
241
+ className={styles.visitors}
242
+ x={column.insetX}
243
+ width={column.insetWidth}
244
+ y={column.insetY}
245
+ height={column.insetHeight}
246
+ />
247
+ </g>
248
+ ))}
249
+ </svg>
250
+
251
+ {/* HTML axis labels remain undistorted while the SVG stretches. */}
252
+ <div className={cx('byline-analytics-axis', styles.axis)} aria-hidden="true">
253
+ <span>{buckets.length > 0 && buckets[0] != null ? formatDay(buckets[0].from) : ''}</span>
254
+ <span>
255
+ {buckets.length > 0 && buckets[buckets.length - 1] != null
256
+ ? formatDay(buckets[buckets.length - 1]?.to ?? '')
257
+ : ''}
258
+ </span>
259
+ </div>
260
+
261
+ <p className={cx('byline-analytics-readout', styles.readout)} aria-live="polite">
262
+ {active == null ? (
263
+ t('analytics.chart.hint')
264
+ ) : (
265
+ <>
266
+ <strong>{formatBucket(active)}</strong>
267
+ <span className={styles.swatchViews} />
268
+ {t('analytics.stats.views')} {numbers.format(active.views)}
269
+ <span className={styles.swatchVisitors} />
270
+ {visitorLabel(active)} {numbers.format(active.visitors)}
271
+ <span className={styles.swatchDownloads} />
272
+ {t('analytics.stats.downloads')} {numbers.format(active.downloads)}
273
+ </>
274
+ )}
275
+ </p>
276
+ </div>
277
+ )
278
+ }
279
+
280
+ function bucketBySevenDays(days: readonly AnalyticsSummaryDay[]): AnalyticsChartBucket[] {
281
+ const buckets: AnalyticsChartBucket[] = []
282
+ for (let index = 0; index < days.length; index += 7) {
283
+ const rows = days.slice(index, index + 7)
284
+ const first = rows[0]
285
+ const last = rows[rows.length - 1]
286
+ if (first != null && last != null) {
287
+ buckets.push(combineRows(first.day, last.day, 'seven-day', rows))
288
+ }
289
+ }
290
+ return buckets
291
+ }
292
+
293
+ function bucketByUtcMonth(days: readonly AnalyticsSummaryDay[]): AnalyticsChartBucket[] {
294
+ const buckets: AnalyticsChartBucket[] = []
295
+ let rows: AnalyticsSummaryDay[] = []
296
+ let month: string | undefined
297
+
298
+ const flush = () => {
299
+ const first = rows[0]
300
+ const last = rows[rows.length - 1]
301
+ if (first != null && last != null) {
302
+ buckets.push(combineRows(first.day, last.day, 'month', rows))
303
+ }
304
+ rows = []
305
+ }
306
+
307
+ for (const day of days) {
308
+ const nextMonth = day.day.slice(0, 7)
309
+ if (month != null && nextMonth !== month) flush()
310
+ month = nextMonth
311
+ rows.push(day)
312
+ }
313
+ flush()
314
+ return buckets
315
+ }
316
+
317
+ function combineRows(
318
+ from: string,
319
+ to: string,
320
+ granularity: Exclude<AnalyticsChartGranularity, 'day'>,
321
+ rows: readonly AnalyticsSummaryDay[]
322
+ ): AnalyticsChartBucket {
323
+ let views = 0
324
+ let visitors = 0
325
+ let downloads = 0
326
+ for (const row of rows) {
327
+ views += row.views
328
+ visitors += row.visitors
329
+ downloads += row.downloads
330
+ }
331
+ return { from, to, granularity, dayCount: rows.length, views, visitors, downloads }
332
+ }
@@ -0,0 +1,14 @@
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
+ export {
10
+ ANALYTICS_ABILITIES,
11
+ type AnalyticsAbilityKey,
12
+ registerAnalyticsAbilities,
13
+ } from './abilities.js'
14
+ export type { AnalyticsDashboardData, AnalyticsDashboardPeriod } from './types.js'
@@ -0,0 +1,31 @@
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 {
10
+ AnalyticsCountryTotal,
11
+ AnalyticsDateRange,
12
+ AnalyticsPathTotal,
13
+ AnalyticsRankedTotals,
14
+ AnalyticsReferrerTotal,
15
+ AnalyticsReportCoverage,
16
+ AnalyticsSummary,
17
+ } from '@byline/analytics'
18
+ import type { AnalyticsDashboardPeriod as PortableAnalyticsDashboardPeriod } from '@byline/analytics/config'
19
+
20
+ export type AnalyticsDashboardPeriod = PortableAnalyticsDashboardPeriod
21
+
22
+ export interface AnalyticsDashboardData {
23
+ range: AnalyticsDateRange
24
+ coverage: AnalyticsReportCoverage
25
+ summary: AnalyticsSummary
26
+ pages: AnalyticsRankedTotals<AnalyticsPathTotal>
27
+ downloads: AnalyticsRankedTotals<AnalyticsPathTotal>
28
+ referrers: AnalyticsRankedTotals<AnalyticsReferrerTotal>
29
+ /** Countries are unbounded by a top-N limit, so there is nothing to truncate. */
30
+ countries: AnalyticsCountryTotal[]
31
+ }
package/src/react.ts CHANGED
@@ -63,6 +63,9 @@ export * from './forms/form-context.js'
63
63
  export * from './forms/form-renderer.js'
64
64
  export * from './forms/navigation-guard.js'
65
65
  export * from './forms/path-widget.js'
66
+ // Shared by the document editor and the cross-collection queue, so both agree
67
+ // on when an armed schedule has become overdue and how loud each state is.
68
+ export * from './forms/scheduled-publication-state.js'
66
69
  // Validation error code → translation helper. Sibling to schemas in
67
70
  // `@byline/core/validation`, which emit codes; this helper maps the
68
71
  // codes onto the active locale for form-input errorText slots.