@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,190 @@
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 { describe, expect, it } from 'vitest'
10
+
11
+ import { deriveScheduledPublicationState } from './scheduled-publication-state.js'
12
+ import type { ScheduledPublicationInfo } from './scheduled-publication-state.js'
13
+
14
+ const NOW = Date.parse('2026-08-22T12:00:00.000Z')
15
+
16
+ const ALL_CAPABILITIES = { canSchedule: true, canConfirm: true, canCancel: true }
17
+ const NO_CAPABILITIES = { canSchedule: false, canConfirm: false, canCancel: false }
18
+
19
+ function schedule(overrides: Partial<ScheduledPublicationInfo> = {}): ScheduledPublicationInfo {
20
+ return {
21
+ publishAt: '2026-08-24T09:00:00.000Z',
22
+ targetVersionId: '019f0000-0000-7000-8000-000000000001',
23
+ state: 'armed',
24
+ lastAuthorizedBy: '019e3daa-42dd-70bb-9be3-5f18b1594b0a',
25
+ lastError: null,
26
+ nextAttemptAt: '2026-08-24T09:00:00.000Z',
27
+ attemptCount: 0,
28
+ ...overrides,
29
+ }
30
+ }
31
+
32
+ describe('deriveScheduledPublicationState', () => {
33
+ describe('with no schedule', () => {
34
+ it('offers only the schedule action', () => {
35
+ const state = deriveScheduledPublicationState(null, ALL_CAPABILITIES, NOW)
36
+
37
+ expect(state.kind).toBe('none')
38
+ expect(state.isExceptional).toBe(false)
39
+ expect(state.actions).toEqual({
40
+ schedule: true,
41
+ reschedule: false,
42
+ confirm: false,
43
+ cancel: false,
44
+ })
45
+ })
46
+
47
+ it('offers nothing at all to an actor without the abilities', () => {
48
+ const state = deriveScheduledPublicationState(null, NO_CAPABILITIES, NOW)
49
+
50
+ expect(state.actions.schedule).toBe(false)
51
+ })
52
+ })
53
+
54
+ describe('armed', () => {
55
+ it('reads as a quiet, non-exceptional state', () => {
56
+ const state = deriveScheduledPublicationState(schedule(), ALL_CAPABILITIES, NOW)
57
+
58
+ expect(state.kind).toBe('armed')
59
+ expect(state.tone).toBe('info')
60
+ expect(state.isExceptional).toBe(false)
61
+ expect(state.isPastDue).toBe(false)
62
+ expect(state.publishAt?.toISOString()).toBe('2026-08-24T09:00:00.000Z')
63
+ })
64
+
65
+ it('offers reschedule and cancel but never confirm', () => {
66
+ const state = deriveScheduledPublicationState(schedule(), ALL_CAPABILITIES, NOW)
67
+
68
+ expect(state.actions).toEqual({
69
+ schedule: false,
70
+ reschedule: true,
71
+ confirm: false,
72
+ cancel: true,
73
+ })
74
+ })
75
+
76
+ it('becomes overdue the instant the authorized time is reached', () => {
77
+ const due = Date.parse('2026-08-24T09:00:00.000Z')
78
+
79
+ expect(deriveScheduledPublicationState(schedule(), ALL_CAPABILITIES, due - 1).kind).toBe(
80
+ 'armed'
81
+ )
82
+ expect(deriveScheduledPublicationState(schedule(), ALL_CAPABILITIES, due).kind).toBe(
83
+ 'overdue'
84
+ )
85
+ })
86
+ })
87
+
88
+ describe('needs re-confirmation', () => {
89
+ it('escalates and offers confirm alongside reschedule and cancel', () => {
90
+ const state = deriveScheduledPublicationState(
91
+ schedule({ state: 'needs_reconfirm' }),
92
+ ALL_CAPABILITIES,
93
+ NOW
94
+ )
95
+
96
+ expect(state.kind).toBe('needs_reconfirm')
97
+ expect(state.tone).toBe('warning')
98
+ expect(state.isExceptional).toBe(true)
99
+ expect(state.actions).toEqual({
100
+ schedule: false,
101
+ reschedule: true,
102
+ confirm: true,
103
+ cancel: true,
104
+ })
105
+ })
106
+
107
+ it('stays suspended rather than overdue once its instant has passed', () => {
108
+ const state = deriveScheduledPublicationState(
109
+ schedule({ state: 'needs_reconfirm' }),
110
+ ALL_CAPABILITIES,
111
+ Date.parse('2026-08-25T09:00:00.000Z')
112
+ )
113
+
114
+ // The reason it did not publish is the suspension, not the clock — but
115
+ // the elapsed instant still has to be reportable to the editor.
116
+ expect(state.kind).toBe('needs_reconfirm')
117
+ expect(state.isPastDue).toBe(true)
118
+ })
119
+
120
+ it('withholds confirm from an actor who cannot confirm', () => {
121
+ const state = deriveScheduledPublicationState(
122
+ schedule({ state: 'needs_reconfirm' }),
123
+ { canSchedule: false, canConfirm: false, canCancel: true },
124
+ NOW
125
+ )
126
+
127
+ expect(state.actions.confirm).toBe(false)
128
+ expect(state.actions.reschedule).toBe(false)
129
+ expect(state.actions.cancel).toBe(true)
130
+ })
131
+ })
132
+
133
+ describe('overdue', () => {
134
+ const overdueNow = Date.parse('2026-08-24T09:05:00.000Z')
135
+
136
+ it('escalates and exposes the retry count', () => {
137
+ const state = deriveScheduledPublicationState(
138
+ schedule({ attemptCount: 3 }),
139
+ ALL_CAPABILITIES,
140
+ overdueNow
141
+ )
142
+
143
+ expect(state.kind).toBe('overdue')
144
+ expect(state.isExceptional).toBe(true)
145
+ expect(state.attemptCount).toBe(3)
146
+ })
147
+
148
+ it('raises the tone to danger when the sweep reported an error', () => {
149
+ const state = deriveScheduledPublicationState(
150
+ schedule({ attemptCount: 2, lastError: 'workflow transition rejected' }),
151
+ ALL_CAPABILITIES,
152
+ overdueNow
153
+ )
154
+
155
+ expect(state.tone).toBe('danger')
156
+ expect(state.lastError).toBe('workflow transition rejected')
157
+ })
158
+
159
+ it('raises the tone to danger even while still armed and not yet due', () => {
160
+ const state = deriveScheduledPublicationState(
161
+ schedule({ lastError: 'transient database error' }),
162
+ ALL_CAPABILITIES,
163
+ NOW
164
+ )
165
+
166
+ expect(state.kind).toBe('armed')
167
+ expect(state.tone).toBe('danger')
168
+ })
169
+
170
+ it('still offers cancel so a stuck schedule can be cleared', () => {
171
+ const state = deriveScheduledPublicationState(
172
+ schedule({ attemptCount: 5, lastError: 'boom' }),
173
+ { canSchedule: false, canConfirm: false, canCancel: true },
174
+ overdueNow
175
+ )
176
+
177
+ expect(state.actions.cancel).toBe(true)
178
+ })
179
+ })
180
+
181
+ it('accepts a Date instant as readily as an ISO string', () => {
182
+ const state = deriveScheduledPublicationState(
183
+ schedule({ publishAt: new Date('2026-08-24T09:00:00.000Z') }),
184
+ ALL_CAPABILITIES,
185
+ NOW
186
+ )
187
+
188
+ expect(state.publishAt?.toISOString()).toBe('2026-08-24T09:00:00.000Z')
189
+ })
190
+ })
@@ -0,0 +1,146 @@
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
+ /**
10
+ * Presentation state for a document's pending publication schedule.
11
+ *
12
+ * The editor surfaces four situations, and they differ in how loudly they
13
+ * should be presented, not just in wording:
14
+ *
15
+ * - `none` — nothing scheduled; only the "schedule" action applies.
16
+ * - `armed` — a future instant is authorized against a reviewed
17
+ * version. Quiet: a metadata cell in the status bar.
18
+ * - `needs_reconfirm` — content was saved after the time was authorized, so
19
+ * the schedule is suspended until an editor reviews and
20
+ * confirms the current version. Loud, and durable: this
21
+ * has to stay discoverable long after the toast is gone.
22
+ * - `overdue` — armed, due, and not yet finalized. The sweep may still
23
+ * be retrying, and `lastError` may carry a bounded
24
+ * reason. Loud.
25
+ *
26
+ * Deriving this in one pure place keeps the branching out of the components and
27
+ * makes the awkward combinations (a suspended schedule whose instant has also
28
+ * passed; an overdue schedule the actor may not cancel) testable without a DOM.
29
+ */
30
+
31
+ export type ScheduledPublicationStateKind = 'none' | 'armed' | 'needs_reconfirm' | 'overdue'
32
+
33
+ /** How prominently the state should be rendered. */
34
+ export type ScheduledPublicationTone = 'neutral' | 'info' | 'warning' | 'danger'
35
+
36
+ export interface ScheduledPublicationInfo {
37
+ publishAt: string | Date
38
+ targetVersionId: string
39
+ state: 'armed' | 'needs_reconfirm'
40
+ lastAuthorizedBy: string | null
41
+ lastError: string | null
42
+ nextAttemptAt: string | Date
43
+ attemptCount: number
44
+ }
45
+
46
+ /**
47
+ * Which operations the host has wired up for this document and actor. A
48
+ * missing handler means the actor cannot perform the operation — the server
49
+ * decides, and the editor never renders an action it cannot complete.
50
+ */
51
+ export interface ScheduledPublicationCapabilities {
52
+ canSchedule: boolean
53
+ canConfirm: boolean
54
+ canCancel: boolean
55
+ }
56
+
57
+ export interface ScheduledPublicationState {
58
+ kind: ScheduledPublicationStateKind
59
+ tone: ScheduledPublicationTone
60
+ /** The authorized instant, or null when nothing is scheduled. */
61
+ publishAt: Date | null
62
+ /**
63
+ * True when the authorized instant has passed. Tracked separately from
64
+ * `kind` so a suspended schedule that is also past due still reads as
65
+ * "needs re-confirmation" — the reason it did not publish — while the
66
+ * elapsed time remains available to the summary.
67
+ */
68
+ isPastDue: boolean
69
+ /** Bounded failure reason from the last sweep attempt, when the server sent one. */
70
+ lastError: string | null
71
+ /** Sweep attempts so far. Only meaningful once the instant has passed. */
72
+ attemptCount: number
73
+ /** True when the state warrants an escalated, dismissible-proof notice rather than a metadata cell. */
74
+ isExceptional: boolean
75
+ actions: {
76
+ schedule: boolean
77
+ reschedule: boolean
78
+ confirm: boolean
79
+ cancel: boolean
80
+ }
81
+ }
82
+
83
+ function toDate(value: string | Date): Date {
84
+ return value instanceof Date ? value : new Date(value)
85
+ }
86
+
87
+ /**
88
+ * Derive the editor's presentation state from the server's schedule record.
89
+ *
90
+ * `now` is injected rather than read from the clock so the boundary between
91
+ * armed and overdue is testable, and so a single render pass cannot disagree
92
+ * with itself.
93
+ */
94
+ export function deriveScheduledPublicationState(
95
+ schedule: ScheduledPublicationInfo | null,
96
+ capabilities: ScheduledPublicationCapabilities,
97
+ now: number
98
+ ): ScheduledPublicationState {
99
+ if (schedule == null) {
100
+ return {
101
+ kind: 'none',
102
+ tone: 'neutral',
103
+ publishAt: null,
104
+ isPastDue: false,
105
+ lastError: null,
106
+ attemptCount: 0,
107
+ isExceptional: false,
108
+ actions: {
109
+ schedule: capabilities.canSchedule,
110
+ reschedule: false,
111
+ confirm: false,
112
+ cancel: false,
113
+ },
114
+ }
115
+ }
116
+
117
+ const publishAt = toDate(schedule.publishAt)
118
+ const isPastDue = Number.isFinite(publishAt.getTime()) && publishAt.getTime() <= now
119
+ const needsReconfirm = schedule.state === 'needs_reconfirm'
120
+ const kind: ScheduledPublicationStateKind = needsReconfirm
121
+ ? 'needs_reconfirm'
122
+ : isPastDue
123
+ ? 'overdue'
124
+ : 'armed'
125
+
126
+ // An error the sweep reported outranks the state's own tone: it is the only
127
+ // signal that automatic publication is actively failing rather than pending.
128
+ const tone: ScheduledPublicationTone =
129
+ schedule.lastError != null ? 'danger' : kind === 'armed' ? 'info' : 'warning'
130
+
131
+ return {
132
+ kind,
133
+ tone,
134
+ publishAt,
135
+ isPastDue,
136
+ lastError: schedule.lastError,
137
+ attemptCount: schedule.attemptCount,
138
+ isExceptional: kind !== 'armed',
139
+ actions: {
140
+ schedule: false,
141
+ reschedule: capabilities.canSchedule,
142
+ confirm: needsReconfirm && capabilities.canConfirm,
143
+ cancel: capabilities.canCancel,
144
+ },
145
+ }
146
+ }
@@ -0,0 +1,86 @@
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 { describe, expect, it } from 'vitest'
10
+
11
+ import {
12
+ joinWallTime,
13
+ resolveScheduledPublicationWallTime,
14
+ wallTimeInZone,
15
+ } from './scheduled-publication-time.js'
16
+
17
+ describe('resolveScheduledPublicationWallTime', () => {
18
+ it('rejects a calendar date that the Date constructor would normalize', () => {
19
+ expect(resolveScheduledPublicationWallTime('2026-02-30T12:00', 'UTC')).toEqual({
20
+ status: 'invalid',
21
+ })
22
+ })
23
+
24
+ it('resolves an ordinary wall time to one ISO instant', () => {
25
+ expect(resolveScheduledPublicationWallTime('2026-08-23T12:00', 'America/New_York')).toEqual({
26
+ status: 'valid',
27
+ choices: [{ iso: '2026-08-23T16:00:00.000Z', offsetLabel: 'UTC-04:00' }],
28
+ })
29
+ })
30
+
31
+ it('rejects a daylight-saving gap instead of silently shifting it', () => {
32
+ expect(resolveScheduledPublicationWallTime('2026-03-08T02:30', 'America/New_York')).toEqual({
33
+ status: 'nonexistent',
34
+ })
35
+ })
36
+
37
+ it('returns both instants in a daylight-saving overlap', () => {
38
+ expect(resolveScheduledPublicationWallTime('2026-11-01T01:30', 'America/New_York')).toEqual({
39
+ status: 'valid',
40
+ choices: [
41
+ { iso: '2026-11-01T05:30:00.000Z', offsetLabel: 'UTC-04:00' },
42
+ { iso: '2026-11-01T06:30:00.000Z', offsetLabel: 'UTC-05:00' },
43
+ ],
44
+ })
45
+ })
46
+ })
47
+
48
+ describe('wallTimeInZone', () => {
49
+ it('splits an instant into the day and clock reading shown in the zone', () => {
50
+ expect(wallTimeInZone(new Date('2026-08-23T16:00:00.000Z'), 'America/New_York')).toEqual({
51
+ date: '2026-08-23',
52
+ time: '12:00',
53
+ })
54
+ })
55
+
56
+ it('reports the previous day when the zone is behind the instant', () => {
57
+ expect(wallTimeInZone(new Date('2026-08-24T02:00:00.000Z'), 'America/New_York')).toEqual({
58
+ date: '2026-08-23',
59
+ time: '22:00',
60
+ })
61
+ })
62
+
63
+ it('round-trips an overlap instant back to the wall time that produced it', () => {
64
+ // The second 01:30 of the fall-back overlap. Both instants share a wall
65
+ // time, so re-opening the modal must show 01:30 rather than a UTC spelling.
66
+ expect(wallTimeInZone(new Date('2026-11-01T06:30:00.000Z'), 'America/New_York')).toEqual({
67
+ date: '2026-11-01',
68
+ time: '01:30',
69
+ })
70
+ })
71
+ })
72
+
73
+ describe('joinWallTime', () => {
74
+ it('joins the two halves into the resolver grammar', () => {
75
+ expect(joinWallTime({ date: '2026-08-23', time: '12:00' })).toBe('2026-08-23T12:00')
76
+ })
77
+
78
+ it('drops the seconds a native time input may append', () => {
79
+ expect(joinWallTime({ date: '2026-08-23', time: '12:00:00' })).toBe('2026-08-23T12:00')
80
+ })
81
+
82
+ it('returns null while either half is still blank', () => {
83
+ expect(joinWallTime({ date: '', time: '12:00' })).toBeNull()
84
+ expect(joinWallTime({ date: '2026-08-23', time: '' })).toBeNull()
85
+ })
86
+ })
@@ -0,0 +1,218 @@
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 interface ScheduledPublicationInstantChoice {
10
+ iso: string
11
+ offsetLabel: string
12
+ }
13
+
14
+ /**
15
+ * A wall time as the editor sees it: a calendar day and a clock reading, with
16
+ * no instant attached yet.
17
+ *
18
+ * The schedule modal takes these two halves from the date picker's
19
+ * `onWallTimeChange` and keeps them as strings all the way to
20
+ * `resolveScheduledPublicationWallTime`, never routing them through a `Date`.
21
+ * That is deliberate: `setHours` silently normalizes a nonexistent wall time (a
22
+ * spring-forward 02:30 becomes 03:30) and silently picks the first of two
23
+ * ambiguous instants at a fall-back overlap. Both are exactly the cases the
24
+ * editor has to be asked about, so the only safe carrier between the picker and
25
+ * the resolver is text.
26
+ */
27
+ export interface ScheduledPublicationWallTime {
28
+ /** Calendar day as `YYYY-MM-DD`. */
29
+ date: string
30
+ /** Clock reading as `HH:mm`, 24-hour. */
31
+ time: string
32
+ }
33
+
34
+ export type ScheduledPublicationWallTimeResolution =
35
+ | { status: 'invalid' }
36
+ | { status: 'nonexistent' }
37
+ | { status: 'valid'; choices: ScheduledPublicationInstantChoice[] }
38
+
39
+ interface WallParts {
40
+ year: number
41
+ month: number
42
+ day: number
43
+ hour: number
44
+ minute: number
45
+ }
46
+
47
+ function parseWallParts(value: string): WallParts | null {
48
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value)
49
+ if (match == null) return null
50
+ const [, year, month, day, hour, minute] = match
51
+ const parts = {
52
+ year: Number(year),
53
+ month: Number(month),
54
+ day: Number(day),
55
+ hour: Number(hour),
56
+ minute: Number(minute),
57
+ }
58
+ if (
59
+ parts.year < 1 ||
60
+ parts.month < 1 ||
61
+ parts.month > 12 ||
62
+ parts.day < 1 ||
63
+ parts.day > 31 ||
64
+ parts.hour > 23 ||
65
+ parts.minute > 59
66
+ ) {
67
+ return null
68
+ }
69
+ const calendar = new Date(0)
70
+ calendar.setUTCFullYear(parts.year, parts.month - 1, parts.day)
71
+ calendar.setUTCHours(parts.hour, parts.minute, 0, 0)
72
+ if (
73
+ calendar.getUTCFullYear() !== parts.year ||
74
+ calendar.getUTCMonth() !== parts.month - 1 ||
75
+ calendar.getUTCDate() !== parts.day
76
+ ) {
77
+ return null
78
+ }
79
+ return parts
80
+ }
81
+
82
+ function formatter(timeZone: string): Intl.DateTimeFormat {
83
+ return new Intl.DateTimeFormat('en-US-u-ca-iso8601-nu-latn', {
84
+ timeZone,
85
+ year: 'numeric',
86
+ month: '2-digit',
87
+ day: '2-digit',
88
+ hour: '2-digit',
89
+ minute: '2-digit',
90
+ second: '2-digit',
91
+ hourCycle: 'h23',
92
+ })
93
+ }
94
+
95
+ function zonedParts(format: Intl.DateTimeFormat, epochMs: number): WallParts & { second: number } {
96
+ const values = Object.fromEntries(
97
+ format
98
+ .formatToParts(epochMs)
99
+ .filter((part) => part.type !== 'literal')
100
+ .map((part) => [part.type, Number(part.value)])
101
+ )
102
+ return {
103
+ year: values.year ?? Number.NaN,
104
+ month: values.month ?? Number.NaN,
105
+ day: values.day ?? Number.NaN,
106
+ hour: values.hour ?? Number.NaN,
107
+ minute: values.minute ?? Number.NaN,
108
+ second: values.second ?? Number.NaN,
109
+ }
110
+ }
111
+
112
+ function sameWallTime(actual: WallParts, expected: WallParts): boolean {
113
+ return (
114
+ actual.year === expected.year &&
115
+ actual.month === expected.month &&
116
+ actual.day === expected.day &&
117
+ actual.hour === expected.hour &&
118
+ actual.minute === expected.minute
119
+ )
120
+ }
121
+
122
+ function offsetMinutes(format: Intl.DateTimeFormat, epochMs: number): number {
123
+ const parts = zonedParts(format, epochMs)
124
+ const localAsUtc = Date.UTC(
125
+ parts.year,
126
+ parts.month - 1,
127
+ parts.day,
128
+ parts.hour,
129
+ parts.minute,
130
+ parts.second
131
+ )
132
+ return Math.round((localAsUtc - epochMs) / 60_000)
133
+ }
134
+
135
+ function formatOffset(minutes: number): string {
136
+ const sign = minutes < 0 ? '-' : '+'
137
+ const absolute = Math.abs(minutes)
138
+ const hours = String(Math.floor(absolute / 60)).padStart(2, '0')
139
+ const remainingMinutes = String(absolute % 60).padStart(2, '0')
140
+ return `UTC${sign}${hours}:${remainingMinutes}`
141
+ }
142
+
143
+ /**
144
+ * Resolve a browser-entered wall time in an explicit IANA zone. Returns no
145
+ * choice for a daylight-saving gap and two choices for an overlap, forcing
146
+ * the editor to choose an actual instant rather than accepting JS Date's
147
+ * silent normalization.
148
+ */
149
+ export function resolveScheduledPublicationWallTime(
150
+ value: string,
151
+ timeZone: string
152
+ ): ScheduledPublicationWallTimeResolution {
153
+ const wall = parseWallParts(value)
154
+ if (wall == null) return { status: 'invalid' }
155
+
156
+ let format: Intl.DateTimeFormat
157
+ try {
158
+ format = formatter(timeZone)
159
+ } catch {
160
+ return { status: 'invalid' }
161
+ }
162
+
163
+ const wallAsUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute)
164
+ const offsets = new Set<number>()
165
+ for (let hours = -36; hours <= 36; hours += 6) {
166
+ offsets.add(offsetMinutes(format, wallAsUtc + hours * 3_600_000))
167
+ }
168
+
169
+ const choices = Array.from(offsets)
170
+ .map((offset) => ({ epochMs: wallAsUtc - offset * 60_000, offset }))
171
+ .filter(({ epochMs }) => sameWallTime(zonedParts(format, epochMs), wall))
172
+ .sort((left, right) => left.epochMs - right.epochMs)
173
+ .map(({ epochMs, offset }) => ({
174
+ iso: new Date(epochMs).toISOString(),
175
+ offsetLabel: formatOffset(offset),
176
+ }))
177
+
178
+ if (choices.length === 0) return { status: 'nonexistent' }
179
+ return { status: 'valid', choices }
180
+ }
181
+
182
+ /**
183
+ * Split an instant into the calendar day and clock reading it shows in
184
+ * `timeZone`. Used to seed the modal when rescheduling, so the editor starts
185
+ * from the time they authorized rather than from its UTC spelling.
186
+ */
187
+ export function wallTimeInZone(instant: Date, timeZone: string): ScheduledPublicationWallTime {
188
+ const parts = Object.fromEntries(
189
+ new Intl.DateTimeFormat('en-US-u-ca-iso8601-nu-latn', {
190
+ timeZone,
191
+ year: 'numeric',
192
+ month: '2-digit',
193
+ day: '2-digit',
194
+ hour: '2-digit',
195
+ minute: '2-digit',
196
+ hourCycle: 'h23',
197
+ })
198
+ .formatToParts(instant)
199
+ .filter((part) => part.type !== 'literal')
200
+ .map((part) => [part.type, part.value])
201
+ )
202
+ return {
203
+ date: `${parts.year}-${parts.month}-${parts.day}`,
204
+ time: `${parts.hour}:${parts.minute}`,
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Join a wall time back into the `YYYY-MM-DDTHH:mm` string that
210
+ * `resolveScheduledPublicationWallTime` parses. Returns null when either half
211
+ * is still blank, which is the modal's "nothing to validate yet" signal.
212
+ */
213
+ export function joinWallTime(wall: ScheduledPublicationWallTime): string | null {
214
+ if (wall.date.length === 0 || wall.time.length === 0) return null
215
+ // Trim to minutes: the resolver's grammar stops there, and scheduling has no
216
+ // sub-minute meaning even if a clock reading arrives carrying seconds.
217
+ return `${wall.date}T${wall.time.slice(0, 5)}`
218
+ }
package/src/index.ts CHANGED
@@ -36,5 +36,6 @@ export * from './modules/admin-activity/index.js'
36
36
  export * from './modules/admin-permissions/index.js'
37
37
  export * from './modules/admin-roles/index.js'
38
38
  export * from './modules/admin-users/index.js'
39
+ export * from './modules/analytics/index.js'
39
40
  export * from './modules/auth/index.js'
40
41
  export type { AdminStore } from './store.js'
@@ -0,0 +1,33 @@
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 { AbilityRegistry } from '@byline/auth'
10
+
11
+ export const ANALYTICS_ABILITIES = {
12
+ read: 'analytics.read',
13
+ maintain: 'analytics.maintain',
14
+ } as const
15
+
16
+ export type AnalyticsAbilityKey = (typeof ANALYTICS_ABILITIES)[keyof typeof ANALYTICS_ABILITIES]
17
+
18
+ export function registerAnalyticsAbilities(registry: AbilityRegistry): void {
19
+ registry.register({
20
+ key: ANALYTICS_ABILITIES.read,
21
+ label: 'Read analytics',
22
+ description: 'View installation-level page, visitor, download, referrer, and country totals.',
23
+ group: 'analytics',
24
+ source: 'admin',
25
+ })
26
+ registry.register({
27
+ key: ANALYTICS_ABILITIES.maintain,
28
+ label: 'Maintain analytics',
29
+ description: 'Delete retained analytics events and rebuild affected daily aggregates.',
30
+ group: 'analytics',
31
+ source: 'admin',
32
+ })
33
+ }