@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.
Files changed (29) hide show
  1. package/dist/abilities.js +2 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +1 -0
  4. package/dist/modules/analytics/abilities.d.ts +14 -0
  5. package/dist/modules/analytics/abilities.js +21 -0
  6. package/dist/modules/analytics/components/dashboard.d.ts +20 -0
  7. package/dist/modules/analytics/components/dashboard.js +308 -0
  8. package/dist/modules/analytics/components/dashboard.module.js +29 -0
  9. package/dist/modules/analytics/components/dashboard.test.node.d.ts +8 -0
  10. package/dist/modules/analytics/components/dashboard_module.css +268 -0
  11. package/dist/modules/analytics/components/timeseries.d.ts +59 -0
  12. package/dist/modules/analytics/components/timeseries.js +253 -0
  13. package/dist/modules/analytics/components/timeseries.module.js +17 -0
  14. package/dist/modules/analytics/components/timeseries_module.css +126 -0
  15. package/dist/modules/analytics/index.d.ts +9 -0
  16. package/dist/modules/analytics/index.js +1 -0
  17. package/dist/modules/analytics/types.d.ts +20 -0
  18. package/dist/modules/analytics/types.js +1 -0
  19. package/package.json +17 -5
  20. package/src/abilities.ts +2 -0
  21. package/src/index.ts +1 -0
  22. package/src/modules/analytics/abilities.ts +33 -0
  23. package/src/modules/analytics/components/dashboard.module.css +330 -0
  24. package/src/modules/analytics/components/dashboard.test.node.ts +193 -0
  25. package/src/modules/analytics/components/dashboard.tsx +371 -0
  26. package/src/modules/analytics/components/timeseries.module.css +151 -0
  27. package/src/modules/analytics/components/timeseries.tsx +332 -0
  28. package/src/modules/analytics/index.ts +14 -0
  29. 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
+ }