@byline/admin 5.1.1 → 5.1.3

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 (78) 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.jsx +208 -0
  66. package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
  67. package/dist/modules/analytics/components/timeseries.jsx +193 -0
  68. package/dist/modules/auth/components/sign-in-form.jsx +89 -0
  69. package/dist/modules/auth/safe-redirect.test.node.js +40 -0
  70. package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
  71. package/dist/presentation/group.jsx +26 -0
  72. package/dist/presentation/row.jsx +23 -0
  73. package/dist/presentation/tabs.jsx +39 -0
  74. package/dist/services/admin-services-context.jsx +17 -0
  75. package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
  76. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
  77. package/dist/widgets/status-badge/status-badge.jsx +46 -0
  78. package/package.json +7 -7
@@ -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
+ }
@@ -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
+ });
@@ -0,0 +1,193 @@
1
+ 'use client';
2
+ import { useMemo, useRef, useState } from 'react';
3
+ import { useTranslation } from '@byline/i18n/react';
4
+ import cx from 'clsx';
5
+ import styles from './timeseries.module.css';
6
+ /** Drawn in a fixed user-space box and stretched to the container width. */
7
+ const VIEWBOX_WIDTH = 900;
8
+ const VIEWBOX_HEIGHT = 180;
9
+ const COLUMN_GAP_RATIO = 0.38;
10
+ const MAX_COLUMN_WIDTH = 26;
11
+ /** Resolve chart density outside the renderer so the chosen width is explicit. */
12
+ export function resolveAnalyticsChartGranularity(period, dayCount) {
13
+ if (typeof period === 'number' || (period === 'all' && dayCount <= 90))
14
+ return 'day';
15
+ if (period === 'ytd' || dayCount <= 732)
16
+ return 'seven-day';
17
+ return 'month';
18
+ }
19
+ /** Combine complete daily query rows using the caller-selected granularity. */
20
+ export function bucketAnalyticsTimeseries(days, granularity) {
21
+ if (granularity === 'day') {
22
+ return days.map((day) => ({
23
+ from: day.day,
24
+ to: day.day,
25
+ granularity,
26
+ dayCount: 1,
27
+ views: day.views,
28
+ visitors: day.visitors,
29
+ downloads: day.downloads,
30
+ }));
31
+ }
32
+ if (granularity === 'seven-day')
33
+ return bucketBySevenDays(days);
34
+ return bucketByUtcMonth(days);
35
+ }
36
+ /** Project reporting buckets into column geometry. */
37
+ export function buildAnalyticsColumns(buckets) {
38
+ if (buckets.length === 0)
39
+ return [];
40
+ const ceiling = Math.max(1, ...buckets.map((bucket) => bucket.views));
41
+ const step = VIEWBOX_WIDTH / buckets.length;
42
+ const width = Math.max(2, Math.min(step * (1 - COLUMN_GAP_RATIO), MAX_COLUMN_WIDTH));
43
+ return buckets.map((bucket, index) => {
44
+ const centre = index * step + step / 2;
45
+ const height = (bucket.views / ceiling) * VIEWBOX_HEIGHT;
46
+ const insetHeight = (bucket.visitors / ceiling) * VIEWBOX_HEIGHT;
47
+ const insetWidth = Math.max(1, width / 2);
48
+ return {
49
+ index,
50
+ x: centre - width / 2,
51
+ width,
52
+ y: VIEWBOX_HEIGHT - height,
53
+ height,
54
+ insetX: centre - insetWidth / 2,
55
+ insetWidth,
56
+ insetY: VIEWBOX_HEIGHT - insetHeight,
57
+ insetHeight,
58
+ hitX: index * step,
59
+ hitWidth: step,
60
+ };
61
+ });
62
+ }
63
+ export function AnalyticsTimeseries({ days, granularity, locale, }) {
64
+ const { t } = useTranslation('byline-admin');
65
+ const [hovered, setHovered] = useState(null);
66
+ const plot = useRef(null);
67
+ const buckets = useMemo(() => bucketAnalyticsTimeseries(days, granularity), [days, granularity]);
68
+ const columns = useMemo(() => buildAnalyticsColumns(buckets), [buckets]);
69
+ const numbers = useMemo(() => new Intl.NumberFormat(locale), [locale]);
70
+ // Stored days are UTC calendar days. Formatting in the viewer's zone would
71
+ // shift the labels by up to one day.
72
+ const dayLabel = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', timeZone: 'UTC' }), [locale]);
73
+ const formatDay = (day) => dayLabel.format(new Date(`${day}T00:00:00.000Z`));
74
+ const formatBucket = (bucket) => bucket.dayCount === 1
75
+ ? formatDay(bucket.from)
76
+ : `${formatDay(bucket.from)} – ${formatDay(bucket.to)}`;
77
+ const visitorLabel = (bucket) => bucket.granularity === 'day'
78
+ ? t('analytics.stats.dailyUniques')
79
+ : t('analytics.stats.summedDailyUniques');
80
+ const active = hovered == null ? undefined : buckets[hovered];
81
+ const readBucket = (bucket) => [
82
+ formatBucket(bucket),
83
+ `${t('analytics.stats.views')} ${numbers.format(bucket.views)}`,
84
+ `${visitorLabel(bucket)} ${numbers.format(bucket.visitors)}`,
85
+ `${t('analytics.stats.downloads')} ${numbers.format(bucket.downloads)}`,
86
+ ].join(', ');
87
+ // One handler on the plot rather than one per column keeps every reporting
88
+ // range to a single pointer listener and one keyboard tab stop.
89
+ const trackPointer = (event) => {
90
+ if (buckets.length === 0 || plot.current == null)
91
+ return;
92
+ const bounds = plot.current.getBoundingClientRect();
93
+ if (bounds.width === 0)
94
+ return;
95
+ const ratio = (event.clientX - bounds.left) / bounds.width;
96
+ setHovered(Math.min(buckets.length - 1, Math.max(0, Math.floor(ratio * buckets.length))));
97
+ };
98
+ const trackKey = (event) => {
99
+ if (buckets.length === 0)
100
+ return;
101
+ const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
102
+ if (step === 0 && event.key !== 'Home' && event.key !== 'End' && event.key !== 'Escape')
103
+ return;
104
+ event.preventDefault();
105
+ if (event.key === 'Escape')
106
+ return setHovered(null);
107
+ if (event.key === 'Home')
108
+ return setHovered(0);
109
+ if (event.key === 'End')
110
+ return setHovered(buckets.length - 1);
111
+ setHovered((current) => {
112
+ const next = (current ?? (step > 0 ? -1 : buckets.length)) + step;
113
+ return Math.min(buckets.length - 1, Math.max(0, next));
114
+ });
115
+ };
116
+ return (<div className={cx('byline-analytics-timeseries', styles.root)} role="slider" tabIndex={0} aria-label={t('analytics.chart.views')} aria-valuemin={0} aria-valuemax={Math.max(0, buckets.length - 1)} aria-valuenow={hovered ?? 0} aria-valuetext={active == null ? t('analytics.chart.hint') : readBucket(active)} onPointerMove={trackPointer} onPointerLeave={() => setHovered(null)} onKeyDown={trackKey} onBlur={() => setHovered(null)}>
117
+ <svg ref={plot} className={cx('byline-analytics-chart', styles.chart)} viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`} preserveAspectRatio="none" aria-hidden="true">
118
+ <title>{t('analytics.chart.views')}</title>
119
+ {[0.5, 1].map((fraction) => (<line key={fraction} className={styles.gridline} x1={0} x2={VIEWBOX_WIDTH} y1={VIEWBOX_HEIGHT - VIEWBOX_HEIGHT * fraction} y2={VIEWBOX_HEIGHT - VIEWBOX_HEIGHT * fraction} vectorEffect="non-scaling-stroke"/>))}
120
+ {columns.map((column) => (<g key={buckets[column.index]?.from} className={cx(styles.column, hovered === column.index && styles.columnActive)}>
121
+ <rect className={styles.views} x={column.x} width={column.width} y={column.y} height={column.height}/>
122
+ <rect className={styles.visitors} x={column.insetX} width={column.insetWidth} y={column.insetY} height={column.insetHeight}/>
123
+ </g>))}
124
+ </svg>
125
+
126
+ {/* HTML axis labels remain undistorted while the SVG stretches. */}
127
+ <div className={cx('byline-analytics-axis', styles.axis)} aria-hidden="true">
128
+ <span>{buckets.length > 0 && buckets[0] != null ? formatDay(buckets[0].from) : ''}</span>
129
+ <span>
130
+ {buckets.length > 0 && buckets[buckets.length - 1] != null
131
+ ? formatDay(buckets[buckets.length - 1]?.to ?? '')
132
+ : ''}
133
+ </span>
134
+ </div>
135
+
136
+ <p className={cx('byline-analytics-readout', styles.readout)} aria-live="polite">
137
+ {active == null ? (t('analytics.chart.hint')) : (<>
138
+ <strong>{formatBucket(active)}</strong>
139
+ <span className={styles.swatchViews}/>
140
+ {t('analytics.stats.views')} {numbers.format(active.views)}
141
+ <span className={styles.swatchVisitors}/>
142
+ {visitorLabel(active)} {numbers.format(active.visitors)}
143
+ <span className={styles.swatchDownloads}/>
144
+ {t('analytics.stats.downloads')} {numbers.format(active.downloads)}
145
+ </>)}
146
+ </p>
147
+ </div>);
148
+ }
149
+ function bucketBySevenDays(days) {
150
+ const buckets = [];
151
+ for (let index = 0; index < days.length; index += 7) {
152
+ const rows = days.slice(index, index + 7);
153
+ const first = rows[0];
154
+ const last = rows[rows.length - 1];
155
+ if (first != null && last != null) {
156
+ buckets.push(combineRows(first.day, last.day, 'seven-day', rows));
157
+ }
158
+ }
159
+ return buckets;
160
+ }
161
+ function bucketByUtcMonth(days) {
162
+ const buckets = [];
163
+ let rows = [];
164
+ let month;
165
+ const flush = () => {
166
+ const first = rows[0];
167
+ const last = rows[rows.length - 1];
168
+ if (first != null && last != null) {
169
+ buckets.push(combineRows(first.day, last.day, 'month', rows));
170
+ }
171
+ rows = [];
172
+ };
173
+ for (const day of days) {
174
+ const nextMonth = day.day.slice(0, 7);
175
+ if (month != null && nextMonth !== month)
176
+ flush();
177
+ month = nextMonth;
178
+ rows.push(day);
179
+ }
180
+ flush();
181
+ return buckets;
182
+ }
183
+ function combineRows(from, to, granularity, rows) {
184
+ let views = 0;
185
+ let visitors = 0;
186
+ let downloads = 0;
187
+ for (const row of rows) {
188
+ views += row.views;
189
+ visitors += row.visitors;
190
+ downloads += row.downloads;
191
+ }
192
+ return { from, to, granularity, dayCount: rows.length, views, visitors, downloads };
193
+ }
@@ -0,0 +1,89 @@
1
+ 'use client';
2
+ /**
3
+ * This Source Code is subject to the terms of the Mozilla Public
4
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
+ *
7
+ * Copyright (c) Infonomic Company Limited
8
+ */
9
+ /**
10
+ * Admin sign-in form.
11
+ *
12
+ * Client component — collects email + password, calls the `adminSignIn`
13
+ * server fn, and on success navigates to a safe caller-supplied destination.
14
+ * On failure renders a generic "Invalid
15
+ * credentials" alert; the provider equalises timing between
16
+ * unknown-email and wrong-password so the UI doesn't distinguish the two.
17
+ *
18
+ * Stable override handles: `.byline-sign-in-card`, `.byline-sign-in-alert`,
19
+ * `.byline-sign-in-form`, `.byline-sign-in-fields`,
20
+ * `.byline-sign-in-actions`, `.byline-sign-in-button`,
21
+ * `.byline-sign-in-home-link`.
22
+ */
23
+ import { useState } from 'react';
24
+ import { getAdminConfig } from '@byline/core';
25
+ import { useTranslation } from '@byline/i18n/react';
26
+ import { Alert, Button, Card, Input, LoaderEllipsis } from '@byline/ui/react';
27
+ import cx from 'clsx';
28
+ import { useBylineAdminServices } from '../../../services/admin-services-context.js';
29
+ import { normalizeRootRelativeRedirect, resolveSignInFormRedirect } from '../safe-redirect.js';
30
+ import styles from './sign-in-form.module.css';
31
+ export function SignInForm({ redirectTo, homeUrl }) {
32
+ const { adminSignIn } = useBylineAdminServices();
33
+ const { t } = useTranslation('byline-admin');
34
+ const [email, setEmail] = useState('');
35
+ const [password, setPassword] = useState('');
36
+ const [pending, setPending] = useState(false);
37
+ const [error, setError] = useState(null);
38
+ async function handleSubmit(event) {
39
+ event.preventDefault();
40
+ if (pending)
41
+ return;
42
+ if (email.trim().length === 0 || password.length === 0) {
43
+ setError(t('auth.signIn.errors.empty'));
44
+ return;
45
+ }
46
+ const destination = resolveSignInFormRedirect(redirectTo, getAdminConfig().routes.admin);
47
+ setPending(true);
48
+ setError(null);
49
+ try {
50
+ await adminSignIn({ data: { email: email.trim(), password } });
51
+ }
52
+ catch (err) {
53
+ console.warn('sign-in failed', err);
54
+ setError(t('auth.signIn.errors.invalidCredentials'));
55
+ setPending(false);
56
+ return;
57
+ }
58
+ // Keep navigation outside the credential error boundary. A browser-level
59
+ // navigation failure must not relabel a successful sign-in as bad credentials.
60
+ window.location.assign(normalizeRootRelativeRedirect(destination) ?? '/');
61
+ }
62
+ return (<Card className={cx('byline-sign-in-card', styles.card)}>
63
+ <Card.Header>
64
+ <Card.Title>
65
+ <h2>{t('auth.signIn.title')}</h2>
66
+ </Card.Title>
67
+ <Card.Description>{t('auth.signIn.description')}</Card.Description>
68
+ {error && (<Alert intent="danger" className={cx('byline-sign-in-alert', styles.alert)}>
69
+ {error}
70
+ </Alert>)}
71
+ </Card.Header>
72
+ <Card.Content>
73
+ <form method="post" onSubmit={handleSubmit} noValidate className={cx('byline-sign-in-form', styles.form)}>
74
+ <div className={cx('byline-sign-in-fields', styles.fields)}>
75
+ <Input label={t('common.fields.email')} id="email" name="email" type="email" autoComplete="email" required value={email} onChange={(event) => setEmail(event.currentTarget.value)} disabled={pending}/>
76
+ <Input label={t('common.fields.password')} id="password" name="password" type="password" autoComplete="current-password" required value={password} onChange={(event) => setPassword(event.currentTarget.value)} disabled={pending}/>
77
+ </div>
78
+ <div className={cx('byline-sign-in-actions', styles.actions)}>
79
+ {homeUrl && (<a href={homeUrl} className={cx('byline-sign-in-home-link', styles['home-link'])}>
80
+ {t('common.actions.home')}
81
+ </a>)}
82
+ <Button type="submit" disabled={pending} className={cx('byline-sign-in-button', styles.button)}>
83
+ {pending ? (<LoaderEllipsis size={30} color="#aaaaaa"/>) : (<span>{t('common.actions.signIn')}</span>)}
84
+ </Button>
85
+ </div>
86
+ </form>
87
+ </Card.Content>
88
+ </Card>);
89
+ }