@byline/admin 4.14.1 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/abilities.js +2 -0
- package/dist/forms/document-actions.d.ts +16 -1
- package/dist/forms/document-actions.js +43 -1
- package/dist/forms/form-renderer.d.ts +6 -1
- package/dist/forms/form-renderer.js +39 -36
- package/dist/forms/form-renderer.module.js +2 -0
- package/dist/forms/form-renderer_module.css +7 -1
- package/dist/forms/form-status-display.d.ts +10 -1
- package/dist/forms/form-status-display.js +4 -2
- package/dist/forms/scheduled-publication-control.d.ts +56 -0
- package/dist/forms/scheduled-publication-control.js +376 -0
- package/dist/forms/scheduled-publication-control.module.js +25 -0
- package/dist/forms/scheduled-publication-control_module.css +95 -0
- package/dist/forms/scheduled-publication-state.d.ts +83 -0
- package/dist/forms/scheduled-publication-state.js +41 -0
- package/dist/forms/scheduled-publication-state.test.node.d.ts +8 -0
- package/dist/forms/scheduled-publication-time.d.ts +57 -0
- package/dist/forms/scheduled-publication-time.js +113 -0
- package/dist/forms/scheduled-publication-time.test.node.d.ts +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/modules/analytics/abilities.d.ts +14 -0
- package/dist/modules/analytics/abilities.js +21 -0
- package/dist/modules/analytics/components/dashboard.d.ts +20 -0
- package/dist/modules/analytics/components/dashboard.js +308 -0
- package/dist/modules/analytics/components/dashboard.module.js +29 -0
- package/dist/modules/analytics/components/dashboard.test.node.d.ts +8 -0
- package/dist/modules/analytics/components/dashboard_module.css +268 -0
- package/dist/modules/analytics/components/timeseries.d.ts +59 -0
- package/dist/modules/analytics/components/timeseries.js +253 -0
- package/dist/modules/analytics/components/timeseries.module.js +17 -0
- package/dist/modules/analytics/components/timeseries_module.css +126 -0
- package/dist/modules/analytics/index.d.ts +9 -0
- package/dist/modules/analytics/index.js +1 -0
- package/dist/modules/analytics/types.d.ts +20 -0
- package/dist/modules/analytics/types.js +1 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +1 -0
- package/package.json +23 -7
- package/src/abilities.ts +2 -0
- package/src/forms/document-actions.tsx +73 -0
- package/src/forms/form-renderer.module.css +15 -0
- package/src/forms/form-renderer.tsx +59 -64
- package/src/forms/form-status-display.tsx +12 -0
- package/src/forms/path-widget.test.tsx +20 -8
- package/src/forms/scheduled-publication-control.module.css +149 -0
- package/src/forms/scheduled-publication-control.tsx +590 -0
- package/src/forms/scheduled-publication-datepicker.test.tsx +89 -0
- package/src/forms/scheduled-publication-state.test.node.ts +190 -0
- package/src/forms/scheduled-publication-state.ts +146 -0
- package/src/forms/scheduled-publication-time.test.node.ts +86 -0
- package/src/forms/scheduled-publication-time.ts +218 -0
- package/src/index.ts +1 -0
- package/src/modules/analytics/abilities.ts +33 -0
- package/src/modules/analytics/components/dashboard.module.css +330 -0
- package/src/modules/analytics/components/dashboard.test.node.ts +193 -0
- package/src/modules/analytics/components/dashboard.tsx +371 -0
- package/src/modules/analytics/components/timeseries.module.css +151 -0
- package/src/modules/analytics/components/timeseries.tsx +332 -0
- package/src/modules/analytics/index.ts +14 -0
- package/src/modules/analytics/types.ts +31 -0
- package/src/react.ts +3 -0
|
@@ -0,0 +1,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
|
+
}
|