@startsimpli/integrations 0.2.48 → 0.2.50
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/package.json +4 -4
- package/src/comms/__tests__/customer-emails-page.test.tsx +1 -1
- package/src/comms/__tests__/fixtures.ts +1 -0
- package/src/comms/__tests__/recipient-local-send.test.tsx +358 -0
- package/src/comms/components/DefaultTimeZoneSetting.tsx +67 -0
- package/src/comms/components/DraftReview.tsx +2 -0
- package/src/comms/components/SchedulePicker.tsx +90 -18
- package/src/comms/components/ScheduledSends.tsx +98 -10
- package/src/comms/components/SendLog.tsx +4 -2
- package/src/comms/components/_shared.tsx +8 -0
- package/src/comms/components/index.ts +1 -0
- package/src/comms/components/pages/CustomerEmailsPage.tsx +4 -0
- package/src/comms/hooks/index.ts +2 -2
- package/src/comms/types/index.ts +62 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.50",
|
|
4
4
|
"description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps, plus foundry customer email (subpath ./comms).",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"react": ">=18.0.0",
|
|
36
36
|
"@tanstack/react-query": ">=5.0.0",
|
|
37
37
|
"@startsimpli/llm": ">=0.4.0",
|
|
38
|
-
"@startsimpli/ui": "^0.4.
|
|
38
|
+
"@startsimpli/ui": "^0.4.164"
|
|
39
39
|
},
|
|
40
40
|
"peerDependenciesMeta": {
|
|
41
41
|
"react": {
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"react-dom": "^19.0.0",
|
|
65
65
|
"typescript": "^6.0.0",
|
|
66
66
|
"vitest": "^3.0.0",
|
|
67
|
-
"@startsimpli/
|
|
68
|
-
"@startsimpli/
|
|
67
|
+
"@startsimpli/ui": "0.4.164",
|
|
68
|
+
"@startsimpli/llm": "0.4.25"
|
|
69
69
|
},
|
|
70
70
|
"scripts": {
|
|
71
71
|
"type-check": "tsc --noEmit",
|
|
@@ -241,7 +241,7 @@ describe('overview (A1)', () => {
|
|
|
241
241
|
await comebackSwitch();
|
|
242
242
|
for (const name of [
|
|
243
243
|
/^comeback sequence settings$/i, /^product updates$/i, /^approval$/i,
|
|
244
|
-
/^voice$/i, /^sending domain$/i, /^results$/i, /^send log$/i,
|
|
244
|
+
/^voice$/i, /^time zone$/i, /^sending domain$/i, /^results$/i, /^send log$/i,
|
|
245
245
|
]) {
|
|
246
246
|
expect(screen.getByRole('button', { name })).toHaveAttribute('aria-expanded', 'false');
|
|
247
247
|
}
|
|
@@ -33,6 +33,7 @@ export const settings = (over: Partial<CommsSettings> = {}): CommsSettings => ({
|
|
|
33
33
|
domain_records: [],
|
|
34
34
|
domain_checked_at: null,
|
|
35
35
|
paused: false,
|
|
36
|
+
default_timezone: '',
|
|
36
37
|
source_repos: [],
|
|
37
38
|
from_address: 'Crochets <crochets@mail.startsimpli.com>',
|
|
38
39
|
...over,
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipient-local-time sends in the comms UI (bd startsim-ofio9.4, epic
|
|
3
|
+
* startsim-ofio9): the schedule picker's "exact time" vs "each recipient's own
|
|
4
|
+
* time zone" choice with the no-known-zone count and the fallback that applies,
|
|
5
|
+
* the default_timezone setting, and per-zone progress in the scheduled list.
|
|
6
|
+
* A mocked Django (msw) behind the REAL createCommsApi client.
|
|
7
|
+
*/
|
|
8
|
+
import * as React from 'react';
|
|
9
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
10
|
+
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
11
|
+
import userEvent from '@testing-library/user-event';
|
|
12
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
13
|
+
import { http, HttpResponse } from 'msw';
|
|
14
|
+
import { setupServer } from 'msw/node';
|
|
15
|
+
|
|
16
|
+
import { DefaultTimeZoneSetting } from '../components/DefaultTimeZoneSetting';
|
|
17
|
+
import { DraftReview } from '../components/DraftReview';
|
|
18
|
+
import { SendLog } from '../components/SendLog';
|
|
19
|
+
import { createCommsApi, setApiBase } from '../api/index';
|
|
20
|
+
import type { CommsSettings, Draft, DraftGroup, ScheduledSend } from '../types/index';
|
|
21
|
+
import { BASE, P, SLUG, draft, settings } from './fixtures';
|
|
22
|
+
|
|
23
|
+
interface Hit { method: string; path: string; body: any }
|
|
24
|
+
let hits: Hit[] = [];
|
|
25
|
+
let drafts: Draft[] = [];
|
|
26
|
+
let scheduledReport: ScheduledSend[] = [];
|
|
27
|
+
let commsSettings: CommsSettings = settings();
|
|
28
|
+
let noTimezone: number | undefined = 3;
|
|
29
|
+
|
|
30
|
+
const rel = (p: string) => p.replace(new URL(P).pathname, '');
|
|
31
|
+
|
|
32
|
+
const server = setupServer(
|
|
33
|
+
http.all(`${P}/*`, async ({ request }) => {
|
|
34
|
+
const url = new URL(request.url);
|
|
35
|
+
const text = request.method === 'GET' ? '' : await request.text();
|
|
36
|
+
const hit: Hit = { method: request.method, path: rel(url.pathname), body: text ? JSON.parse(text) : undefined };
|
|
37
|
+
hits.push(hit);
|
|
38
|
+
const { method, path, body } = hit;
|
|
39
|
+
let m: RegExpMatchArray | null;
|
|
40
|
+
if (path === '/settings/') {
|
|
41
|
+
if (method === 'PATCH') commsSettings = { ...commsSettings, ...body };
|
|
42
|
+
return HttpResponse.json(commsSettings);
|
|
43
|
+
}
|
|
44
|
+
if (path === '/drafts/' && method === 'GET') {
|
|
45
|
+
const wanted = url.searchParams.get('status');
|
|
46
|
+
const results = drafts.filter((d) => !wanted || d.approval === wanted);
|
|
47
|
+
return HttpResponse.json({ count: results.length, next: null, previous: null, results });
|
|
48
|
+
}
|
|
49
|
+
if ((m = path.match(/^\/drafts\/([\w-]+)\/(approve|reschedule|cancel)\/$/)) && method === 'POST') {
|
|
50
|
+
const d = drafts.find((x) => x.id === m![1])!;
|
|
51
|
+
if (m[2] === 'approve' && body?.send_mode === 'recipient_local') {
|
|
52
|
+
Object.assign(d, { approval: 'scheduled', state: 'scheduled', send_mode: 'recipient_local', local_time: `${body.send_at}:00`, send_at: '2026-11-19T19:00:00+00:00', groups: [] });
|
|
53
|
+
} else if (m[2] === 'approve') {
|
|
54
|
+
d.approval = 'approved';
|
|
55
|
+
} else if (m[2] === 'reschedule') {
|
|
56
|
+
Object.assign(d, { local_time: `${body.send_at}:00` });
|
|
57
|
+
} else {
|
|
58
|
+
Object.assign(d, { approval: 'sent', state: 'sent', notice: { code: 'cancelled_partial', reason: 'Cancelled. 6 recipients in 5 time zones had not been sent and will not be. 1 recipient in 1 time zone was already sent or started and cannot be recalled.' } });
|
|
59
|
+
}
|
|
60
|
+
return HttpResponse.json(d);
|
|
61
|
+
}
|
|
62
|
+
if ((m = path.match(/^\/drafts\/([\w-]+)\/audience\/$/)) && method === 'GET') {
|
|
63
|
+
const preview: any = { count: 7, eligible: 7, excluded: {}, excluded_total: 0, sample: [], excluded_sample: [] };
|
|
64
|
+
if (noTimezone !== undefined) preview.no_timezone = noTimezone;
|
|
65
|
+
return HttpResponse.json({ audience: { base: 'all' }, is_default: true, preview });
|
|
66
|
+
}
|
|
67
|
+
if (path === '/sends/' && method === 'GET') {
|
|
68
|
+
return HttpResponse.json({ count: 0, next: null, previous: null, results: [], scheduled: scheduledReport });
|
|
69
|
+
}
|
|
70
|
+
return HttpResponse.json({ detail: `unhandled ${method} ${path}` }, { status: 599 });
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
beforeAll(() => {
|
|
75
|
+
server.listen({ onUnhandledRequest: 'error' });
|
|
76
|
+
setApiBase(`${BASE}/api/v1`);
|
|
77
|
+
});
|
|
78
|
+
afterEach(() => server.resetHandlers());
|
|
79
|
+
afterAll(() => server.close());
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
hits = [];
|
|
82
|
+
drafts = [draft()];
|
|
83
|
+
scheduledReport = [];
|
|
84
|
+
commsSettings = settings();
|
|
85
|
+
noTimezone = 3;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function renderWith(node: (api: ReturnType<typeof createCommsApi>) => React.ReactElement) {
|
|
89
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
|
90
|
+
return render(<QueryClientProvider client={qc}>{node(createCommsApi(SLUG))}</QueryClientProvider>);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const writes = () => hits.filter((h) => h.method !== 'GET');
|
|
94
|
+
const LOCAL = 'In each recipient.s own time zone';
|
|
95
|
+
const EXACT = 'At this exact time';
|
|
96
|
+
|
|
97
|
+
async function openPicker() {
|
|
98
|
+
await userEvent.click(await screen.findByRole('button', { name: /schedule/i }));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const group = (over: Partial<DraftGroup>): DraftGroup => ({
|
|
102
|
+
zone: 'Asia/Tokyo', at: '2026-11-20T00:00:00+00:00', state: 'waiting', late: false, note: null,
|
|
103
|
+
sources: { browser: 1, foundry_default: 0, utc: 0 }, count: 1, pending: 1, sent: 0, failed: 0, suppressed: 0,
|
|
104
|
+
started_at: null, completed_at: null, failure: null, reason: null, ...over,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const rolling = (over: Partial<Draft> = {}): Draft => draft({
|
|
108
|
+
approval: 'rolling', state: 'rolling', next_actions: ['reschedule', 'send_now', 'cancel'],
|
|
109
|
+
send_mode: 'recipient_local', local_time: '2026-11-20T09:00:00', send_at: '2026-11-20T00:00:00+00:00',
|
|
110
|
+
recipient_count: 3,
|
|
111
|
+
groups: [
|
|
112
|
+
group({ zone: 'Pacific/Kiritimati', at: '2026-11-19T19:00:00+00:00', state: 'sent', pending: 0, sent: 1 }),
|
|
113
|
+
group({ zone: 'Asia/Tokyo', state: 'sending', count: 2, pending: 1, sent: 1 }),
|
|
114
|
+
group({ zone: 'America/New_York', at: '2026-11-20T14:00:00+00:00' }),
|
|
115
|
+
],
|
|
116
|
+
group_summary: { total: 3, waiting: 1, sending: 1, sent: 1, failed: 0, expired: 0, cancelled: 0 },
|
|
117
|
+
...over,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('the schedule picker offers two ways to time a send', () => {
|
|
121
|
+
it('defaults to the exact time and shows the choice', async () => {
|
|
122
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
123
|
+
await openPicker();
|
|
124
|
+
expect(screen.getByRole('radio', { name: EXACT })).toBeChecked();
|
|
125
|
+
expect(screen.getByRole('radio', { name: new RegExp(LOCAL, 'i') })).not.toBeChecked();
|
|
126
|
+
expect(screen.getByLabelText('Time zone')).toBeInTheDocument();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('exact mode still posts the instant with its zone and no send_mode', async () => {
|
|
130
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
131
|
+
await openPicker();
|
|
132
|
+
fireEvent.change(screen.getByLabelText('Send at'), { target: { value: '2026-11-03T10:00' } });
|
|
133
|
+
await userEvent.selectOptions(screen.getByLabelText('Time zone'), 'Asia/Tokyo');
|
|
134
|
+
await userEvent.click(screen.getByRole('button', { name: /approve and schedule/i }));
|
|
135
|
+
await waitFor(() => expect(writes()[0].body).toEqual({ send_at: '2026-11-03T10:00', timezone: 'Asia/Tokyo' }));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('local mode posts the wall-clock time with send_mode and no time zone', async () => {
|
|
139
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
140
|
+
await openPicker();
|
|
141
|
+
await userEvent.click(screen.getByRole('radio', { name: new RegExp(LOCAL, 'i') }));
|
|
142
|
+
expect(screen.queryByLabelText('Time zone')).toBeNull();
|
|
143
|
+
fireEvent.change(screen.getByLabelText('Send at'), { target: { value: '2026-11-20T09:00' } });
|
|
144
|
+
await userEvent.click(screen.getByRole('button', { name: /approve and schedule/i }));
|
|
145
|
+
await waitFor(() =>
|
|
146
|
+
expect(writes()[0]).toMatchObject({
|
|
147
|
+
method: 'POST', path: '/drafts/d-1/approve/', body: { send_at: '2026-11-20T09:00', send_mode: 'recipient_local' },
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
expect(writes()[0].body.timezone).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('says how many recipients have no known zone and that UTC applies when no default is set', async () => {
|
|
154
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
155
|
+
await openPicker();
|
|
156
|
+
await userEvent.click(screen.getByRole('radio', { name: new RegExp(LOCAL, 'i') }));
|
|
157
|
+
expect(await screen.findByText(/3 recipients have no known time zone/i)).toBeInTheDocument();
|
|
158
|
+
expect(await screen.findByText(/no default time zone is set.*UTC/i)).toBeInTheDocument();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('names the default time zone as the fallback when one is set', async () => {
|
|
162
|
+
commsSettings = settings({ default_timezone: 'Europe/Paris' });
|
|
163
|
+
noTimezone = 1;
|
|
164
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
165
|
+
await openPicker();
|
|
166
|
+
await userEvent.click(screen.getByRole('radio', { name: new RegExp(LOCAL, 'i') }));
|
|
167
|
+
expect(await screen.findByText(/1 recipient has no known time zone/i)).toBeInTheDocument();
|
|
168
|
+
expect(await screen.findByText(/they get Europe\/Paris, your default time zone/i)).toBeInTheDocument();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('says so when every recipient has a known zone', async () => {
|
|
172
|
+
noTimezone = 0;
|
|
173
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
174
|
+
await openPicker();
|
|
175
|
+
await userEvent.click(screen.getByRole('radio', { name: new RegExp(LOCAL, 'i') }));
|
|
176
|
+
expect(await screen.findByText(/every recipient has a known time zone/i)).toBeInTheDocument();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('explains the span and the late-zone rule in plain words', async () => {
|
|
180
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
181
|
+
await openPicker();
|
|
182
|
+
await userEvent.click(screen.getByRole('radio', { name: new RegExp(LOCAL, 'i') }));
|
|
183
|
+
expect(screen.getByText(/up to 26 hours/i)).toBeInTheDocument();
|
|
184
|
+
expect(screen.getByText(/zones where that time has passed send immediately/i)).toBeInTheDocument();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('asks the audience only once local mode is chosen', async () => {
|
|
188
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
189
|
+
await openPicker();
|
|
190
|
+
expect(hits.filter((h) => h.path.endsWith('/audience/') && h.path.includes('d-1'))).toHaveLength(0);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('a scheduled recipient-local draft', () => {
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
drafts = [draft({
|
|
197
|
+
approval: 'scheduled', state: 'scheduled', send_mode: 'recipient_local', local_time: '2026-11-20T09:00:00',
|
|
198
|
+
send_at: '2026-11-19T19:00:00+00:00', send_timezone: null, recipient_count: 7, groups: [],
|
|
199
|
+
})];
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('is listed as 9:00 in each recipient\'s own time zone', async () => {
|
|
203
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
204
|
+
const heading = await screen.findByRole('heading', { name: /scheduled to send/i });
|
|
205
|
+
const list = within(heading.parentElement as HTMLElement);
|
|
206
|
+
expect(await list.findByText(/in each recipient.s own time zone/i)).toBeInTheDocument();
|
|
207
|
+
expect(list.getByText(/9:00/)).toBeInTheDocument();
|
|
208
|
+
expect(list.getByText(/people who join after the first zone starts are not included/i)).toBeInTheDocument();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('reschedules with the local picker preselected', async () => {
|
|
212
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
213
|
+
await userEvent.click(await screen.findByRole('button', { name: /^reschedule$/i }));
|
|
214
|
+
expect(screen.getByRole('radio', { name: new RegExp(LOCAL, 'i') })).toBeChecked();
|
|
215
|
+
fireEvent.change(screen.getByLabelText('Send at'), { target: { value: '2026-11-22T08:30' } });
|
|
216
|
+
await userEvent.click(screen.getByRole('button', { name: /move send/i }));
|
|
217
|
+
await waitFor(() =>
|
|
218
|
+
expect(writes()[0]).toMatchObject({
|
|
219
|
+
path: '/drafts/d-1/reschedule/', body: { send_at: '2026-11-22T08:30', send_mode: 'recipient_local' },
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('a rolling recipient-local draft', () => {
|
|
226
|
+
beforeEach(() => {
|
|
227
|
+
drafts = [rolling()];
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('shows per-zone progress, soonest first', async () => {
|
|
231
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
232
|
+
const table = await screen.findByRole('table', { name: /progress by time zone/i });
|
|
233
|
+
const rows = within(table).getAllByRole('row').slice(1);
|
|
234
|
+
expect(rows).toHaveLength(3);
|
|
235
|
+
expect(rows[0]).toHaveTextContent('Pacific/Kiritimati');
|
|
236
|
+
expect(rows[0]).toHaveTextContent(/sent/i);
|
|
237
|
+
expect(rows[0]).toHaveTextContent('1 of 1');
|
|
238
|
+
expect(rows[1]).toHaveTextContent('Asia/Tokyo');
|
|
239
|
+
expect(rows[1]).toHaveTextContent(/sending/i);
|
|
240
|
+
expect(rows[1]).toHaveTextContent('1 of 2');
|
|
241
|
+
expect(rows[2]).toHaveTextContent('America/New_York');
|
|
242
|
+
expect(rows[2]).toHaveTextContent(/waiting/i);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('summarises how many zones have gone out', async () => {
|
|
246
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
247
|
+
expect(await screen.findByText(/2 of 3 time zones started/i)).toBeInTheDocument();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('says what cancel does to zones already sent', async () => {
|
|
251
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
252
|
+
expect(await screen.findByText(/zones already sent cannot be recalled/i)).toBeInTheDocument();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('cancel posts cancel and shows what the server says was and was not sent', async () => {
|
|
256
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
257
|
+
await userEvent.click(await screen.findByRole('button', { name: /cancel remaining zones/i }));
|
|
258
|
+
await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/drafts/d-1/cancel/' }));
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('shows a failed zone with its reason', async () => {
|
|
262
|
+
drafts = [rolling({
|
|
263
|
+
groups: [group({ zone: 'Asia/Tokyo', state: 'failed', pending: 2, count: 2,
|
|
264
|
+
failure: { stage: 'send', code: 'recipients_not_sent', reason: '2 recipients still unsent after 3 retries' } })],
|
|
265
|
+
})];
|
|
266
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
267
|
+
const table = await screen.findByRole('table', { name: /progress by time zone/i });
|
|
268
|
+
expect(within(table).getByText(/2 recipients still unsent after 3 retries/i)).toBeInTheDocument();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('marks a zone that went out late and one with a clock-change note', async () => {
|
|
272
|
+
drafts = [rolling({
|
|
273
|
+
groups: [
|
|
274
|
+
group({ zone: 'Asia/Tokyo', state: 'sending', late: true }),
|
|
275
|
+
group({ zone: 'America/New_York', state: 'waiting', note: 'gap' }),
|
|
276
|
+
],
|
|
277
|
+
})];
|
|
278
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
279
|
+
const table = await screen.findByRole('table', { name: /progress by time zone/i });
|
|
280
|
+
expect(within(table).getByText(/sent late/i)).toBeInTheDocument();
|
|
281
|
+
expect(within(table).getByText(/clock change skips this time/i)).toBeInTheDocument();
|
|
282
|
+
expect(within(table).getByText(/first moment after the change/i)).toBeInTheDocument();
|
|
283
|
+
expect(within(table).queryByText(/elapsed/i)).toBeNull();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('says the plan is frozen at the first zone, before and after it starts', async () => {
|
|
287
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
288
|
+
expect(await screen.findByText(/people who join after the first zone starts are not included/i)).toBeInTheDocument();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('Send now asks first, says what it does, and posts only on confirm', async () => {
|
|
292
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
293
|
+
await userEvent.click(await screen.findByRole('button', { name: /^send now$/i }));
|
|
294
|
+
expect(writes()).toEqual([]);
|
|
295
|
+
expect(screen.getByText(/send now starts every waiting time zone immediately/i)).toBeInTheDocument();
|
|
296
|
+
await userEvent.click(screen.getByRole('button', { name: /yes, start every waiting zone/i }));
|
|
297
|
+
await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/drafts/d-1/approve/' }));
|
|
298
|
+
expect(writes()[0].body).toBeUndefined();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('Send now can be backed out of with no write', async () => {
|
|
302
|
+
renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
|
|
303
|
+
await userEvent.click(await screen.findByRole('button', { name: /^send now$/i }));
|
|
304
|
+
await userEvent.click(screen.getByRole('button', { name: /keep waiting/i }));
|
|
305
|
+
expect(screen.queryByText(/send now starts every waiting time zone/i)).toBeNull();
|
|
306
|
+
expect(writes()).toEqual([]);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('send log', () => {
|
|
311
|
+
it('lists a rolling draft with its progress reason', async () => {
|
|
312
|
+
scheduledReport = [{
|
|
313
|
+
id: 'r-1', program_key: 'announcement', subject: 'Global news', status: 'rolling',
|
|
314
|
+
reason: '2 of 3 time zones started; next: America/New_York at 2026-11-20T14:00:00+00:00',
|
|
315
|
+
send_at: '2026-11-20T14:00:00+00:00', send_timezone: null, cancelled_at: null,
|
|
316
|
+
send_mode: 'recipient_local', local_time: '2026-11-20T09:00:00', groups: [], group_summary: null,
|
|
317
|
+
}];
|
|
318
|
+
renderWith((api) => <SendLog api={api} />);
|
|
319
|
+
expect(await screen.findByText('Global news')).toBeInTheDocument();
|
|
320
|
+
expect(screen.getByText('rolling')).toBeInTheDocument();
|
|
321
|
+
expect(screen.getByText(/2 of 3 time zones started/i)).toBeInTheDocument();
|
|
322
|
+
expect(screen.getByText(/in each recipient.s own time zone/i)).toBeInTheDocument();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('default time zone setting', () => {
|
|
327
|
+
it('shows the current value and saves a new one', async () => {
|
|
328
|
+
commsSettings = settings({ default_timezone: 'Europe/Paris' });
|
|
329
|
+
renderWith((api) => <DefaultTimeZoneSetting api={api} />);
|
|
330
|
+
const select = await screen.findByLabelText(/default time zone/i);
|
|
331
|
+
expect(select).toHaveValue('Europe/Paris');
|
|
332
|
+
await userEvent.selectOptions(select, 'America/Denver');
|
|
333
|
+
await userEvent.click(screen.getByRole('button', { name: /save/i }));
|
|
334
|
+
await waitFor(() =>
|
|
335
|
+
expect(writes()[0]).toMatchObject({ method: 'PATCH', path: '/settings/', body: { default_timezone: 'America/Denver' } }),
|
|
336
|
+
);
|
|
337
|
+
expect(await screen.findByText(/saved/i)).toBeInTheDocument();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('can be cleared back to UTC', async () => {
|
|
341
|
+
commsSettings = settings({ default_timezone: 'Europe/Paris' });
|
|
342
|
+
renderWith((api) => <DefaultTimeZoneSetting api={api} />);
|
|
343
|
+
await userEvent.selectOptions(await screen.findByLabelText(/default time zone/i), '');
|
|
344
|
+
await userEvent.click(screen.getByRole('button', { name: /save/i }));
|
|
345
|
+
await waitFor(() => expect(writes()[0].body).toEqual({ default_timezone: '' }));
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('explains what it is for', async () => {
|
|
349
|
+
renderWith((api) => <DefaultTimeZoneSetting api={api} />);
|
|
350
|
+
expect(await screen.findByText(/used for recipients whose own time zone is not known/i)).toBeInTheDocument();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('makes no write until Save', async () => {
|
|
354
|
+
renderWith((api) => <DefaultTimeZoneSetting api={api} />);
|
|
355
|
+
await screen.findByLabelText(/default time zone/i);
|
|
356
|
+
expect(writes()).toEqual([]);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The foundry's default time zone (bd startsim-ofio9.4): the zone used for
|
|
3
|
+
* recipients whose own zone is not known when a send is timed per recipient.
|
|
4
|
+
* Blank means UTC. PATCH settings.default_timezone.
|
|
5
|
+
*/
|
|
6
|
+
import { useState, type ReactElement } from 'react';
|
|
7
|
+
import { Button, Label } from '@startsimpli/ui';
|
|
8
|
+
|
|
9
|
+
import type { CommsApi } from '../api/comms';
|
|
10
|
+
import { useCommsSettings, useUpdateCommsSettings } from '../hooks/index';
|
|
11
|
+
import { ErrorNotice, NativeSelect, Note, timeZoneOptions, useFieldId } from './_shared';
|
|
12
|
+
|
|
13
|
+
export interface DefaultTimeZoneSettingProps {
|
|
14
|
+
api: CommsApi;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function Form({ api, current }: { api: CommsApi; current: string }) {
|
|
18
|
+
const update = useUpdateCommsSettings(api);
|
|
19
|
+
const id = useFieldId('comms-default-zone');
|
|
20
|
+
const [zone, setZone] = useState(current);
|
|
21
|
+
const [saved, setSaved] = useState(false);
|
|
22
|
+
const options = Array.from(new Set([...(current ? [current] : []), ...timeZoneOptions()]));
|
|
23
|
+
return (
|
|
24
|
+
<form
|
|
25
|
+
className="space-y-3"
|
|
26
|
+
onSubmit={(e) => {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
setSaved(false);
|
|
29
|
+
update.mutate({ default_timezone: zone }, { onSuccess: () => setSaved(true) });
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
<div className="space-y-1">
|
|
33
|
+
<Label htmlFor={id}>Default time zone</Label>
|
|
34
|
+
<div>
|
|
35
|
+
<NativeSelect
|
|
36
|
+
id={id}
|
|
37
|
+
value={zone}
|
|
38
|
+
onChange={(e) => {
|
|
39
|
+
setSaved(false);
|
|
40
|
+
setZone(e.target.value);
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
<option value="">Not set (UTC)</option>
|
|
44
|
+
{options.map((z) => (
|
|
45
|
+
<option key={z} value={z}>{z}</option>
|
|
46
|
+
))}
|
|
47
|
+
</NativeSelect>
|
|
48
|
+
</div>
|
|
49
|
+
<p className="text-sm text-muted-foreground">
|
|
50
|
+
Used for recipients whose own time zone is not known, when an email goes out at the same local time for
|
|
51
|
+
everyone. Without one, those recipients get UTC.
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
<div className="flex items-center gap-3">
|
|
55
|
+
<Button type="submit" disabled={update.isPending}>Save time zone</Button>
|
|
56
|
+
{saved ? <Note>Saved.</Note> : null}
|
|
57
|
+
</div>
|
|
58
|
+
<ErrorNotice error={update.error} />
|
|
59
|
+
</form>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function DefaultTimeZoneSetting({ api }: DefaultTimeZoneSettingProps): ReactElement | null {
|
|
64
|
+
const { data: settings } = useCommsSettings(api);
|
|
65
|
+
if (!settings) return null;
|
|
66
|
+
return <Form api={api} current={settings.default_timezone ?? ''} />;
|
|
67
|
+
}
|
|
@@ -190,6 +190,8 @@ export function DraftItem({
|
|
|
190
190
|
{previewing && preview.data ? <EmailPreview email={preview.data} /> : null}
|
|
191
191
|
{scheduling && ready ? (
|
|
192
192
|
<SchedulePicker
|
|
193
|
+
api={api}
|
|
194
|
+
draftId={draft.id}
|
|
193
195
|
submitLabel="Approve and schedule"
|
|
194
196
|
pending={approve.isPending}
|
|
195
197
|
onCancel={() => setScheduling(false)}
|
|
@@ -1,51 +1,123 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pick when an approved draft goes out
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Pick when an approved draft goes out. Two ways:
|
|
3
|
+
* - "At this exact time": a local date and time plus the time zone it is meant
|
|
4
|
+
* in (default: the viewer's). The server stores the instant in UTC.
|
|
5
|
+
* - "At this time in each recipient's own time zone": one wall-clock time that
|
|
6
|
+
* every recipient gets on their own clock (9:00 for everyone, wherever they
|
|
7
|
+
* are). Shows how many recipients have no known zone and which fallback
|
|
8
|
+
* applies to them (the foundry's default time zone, else UTC).
|
|
5
9
|
*/
|
|
6
10
|
import { useState, type ReactElement } from 'react';
|
|
7
11
|
import { Button, Input, Label } from '@startsimpli/ui';
|
|
8
12
|
|
|
9
|
-
import type {
|
|
13
|
+
import type { CommsApi } from '../api/comms';
|
|
14
|
+
import { useCommsSettings, useDraftAudience } from '../hooks/index';
|
|
15
|
+
import type { ScheduleInput, SendMode } from '../types/index';
|
|
10
16
|
import { NativeSelect, localTimeZone, timeZoneOptions, useFieldId } from './_shared';
|
|
11
17
|
|
|
12
18
|
export interface SchedulePickerProps {
|
|
19
|
+
api: CommsApi;
|
|
20
|
+
/** The draft being scheduled: its audience says how many recipients have no known zone. */
|
|
21
|
+
draftId: string;
|
|
13
22
|
submitLabel: string;
|
|
14
23
|
pending?: boolean;
|
|
24
|
+
/** Start on the per-recipient mode (rescheduling a recipient-local draft). */
|
|
25
|
+
initialMode?: SendMode;
|
|
26
|
+
/** Hide the exact-time choice: a rolling recipient-local draft can only take a local time. */
|
|
27
|
+
localOnly?: boolean;
|
|
15
28
|
onSubmit: (schedule: ScheduleInput) => void;
|
|
16
29
|
onCancel: () => void;
|
|
17
30
|
}
|
|
18
31
|
|
|
19
|
-
|
|
32
|
+
/** Who has no zone of their own, and what they get instead. */
|
|
33
|
+
function ZoneNotice({ api, draftId }: { api: CommsApi; draftId: string }): ReactElement | null {
|
|
34
|
+
const audience = useDraftAudience(api, draftId, true).data?.preview;
|
|
35
|
+
const settings = useCommsSettings(api).data;
|
|
36
|
+
if (audience?.no_timezone === undefined) return null;
|
|
37
|
+
const n = audience.no_timezone;
|
|
38
|
+
if (n === 0) {
|
|
39
|
+
return <p className="basis-full text-xs text-muted-foreground">Every recipient has a known time zone.</p>;
|
|
40
|
+
}
|
|
41
|
+
const fallback = settings
|
|
42
|
+
? settings.default_timezone
|
|
43
|
+
? `They get ${settings.default_timezone}, your default time zone.`
|
|
44
|
+
: 'No default time zone is set, so they get UTC.'
|
|
45
|
+
: '';
|
|
46
|
+
return (
|
|
47
|
+
<p className="basis-full text-xs text-muted-foreground">
|
|
48
|
+
{`${n} ${n === 1 ? 'recipient has' : 'recipients have'} no known time zone. ${fallback}`.trim()}
|
|
49
|
+
</p>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function SchedulePicker({
|
|
54
|
+
api,
|
|
55
|
+
draftId,
|
|
56
|
+
submitLabel,
|
|
57
|
+
pending,
|
|
58
|
+
initialMode = 'fixed',
|
|
59
|
+
localOnly,
|
|
60
|
+
onSubmit,
|
|
61
|
+
onCancel,
|
|
62
|
+
}: SchedulePickerProps): ReactElement {
|
|
63
|
+
const [mode, setMode] = useState<SendMode>(localOnly ? 'recipient_local' : initialMode);
|
|
20
64
|
const [when, setWhen] = useState('');
|
|
21
65
|
const [zone, setZone] = useState(localTimeZone);
|
|
22
66
|
const whenId = useFieldId('comms-send-at');
|
|
23
67
|
const zoneId = useFieldId('comms-send-zone');
|
|
68
|
+
const modeName = useFieldId('comms-send-mode');
|
|
69
|
+
const local = mode === 'recipient_local';
|
|
24
70
|
return (
|
|
25
71
|
<form
|
|
26
72
|
className="mt-2 flex flex-wrap items-end gap-3 rounded-md border p-3"
|
|
27
73
|
onSubmit={(e) => {
|
|
28
74
|
e.preventDefault();
|
|
29
|
-
if (when)
|
|
75
|
+
if (!when) return;
|
|
76
|
+
onSubmit(local ? { send_at: when, send_mode: 'recipient_local' } : { send_at: when, timezone: zone });
|
|
30
77
|
}}
|
|
31
78
|
>
|
|
79
|
+
{localOnly ? null : (
|
|
80
|
+
<fieldset className="basis-full space-y-1">
|
|
81
|
+
<legend className="sr-only">When it goes out</legend>
|
|
82
|
+
<label className="flex items-center gap-2 text-sm">
|
|
83
|
+
<input type="radio" name={modeName} checked={!local} onChange={() => setMode('fixed')} />
|
|
84
|
+
At this exact time
|
|
85
|
+
</label>
|
|
86
|
+
<label className="flex items-center gap-2 text-sm">
|
|
87
|
+
<input type="radio" name={modeName} checked={local} onChange={() => setMode('recipient_local')} />
|
|
88
|
+
At this time in each recipient's own time zone
|
|
89
|
+
</label>
|
|
90
|
+
</fieldset>
|
|
91
|
+
)}
|
|
32
92
|
<div className="space-y-1">
|
|
33
93
|
<Label htmlFor={whenId}>Send at</Label>
|
|
34
94
|
<Input id={whenId} type="datetime-local" required value={when} onChange={(e) => setWhen(e.target.value)} />
|
|
35
95
|
</div>
|
|
36
|
-
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
<
|
|
40
|
-
{
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
96
|
+
{local ? null : (
|
|
97
|
+
<div className="space-y-1">
|
|
98
|
+
<Label htmlFor={zoneId}>Time zone</Label>
|
|
99
|
+
<div>
|
|
100
|
+
<NativeSelect id={zoneId} value={zone} onChange={(e) => setZone(e.target.value)}>
|
|
101
|
+
{timeZoneOptions().map((z) => (
|
|
102
|
+
<option key={z} value={z}>{z}</option>
|
|
103
|
+
))}
|
|
104
|
+
</NativeSelect>
|
|
105
|
+
</div>
|
|
44
106
|
</div>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
107
|
+
)}
|
|
108
|
+
{local ? (
|
|
109
|
+
<>
|
|
110
|
+
<p className="basis-full text-xs text-muted-foreground">
|
|
111
|
+
Goes out at that time on each recipient's own clock, starting with the earliest zones. A worldwide
|
|
112
|
+
audience takes up to 26 hours to finish. Zones where that time has passed send immediately.
|
|
113
|
+
</p>
|
|
114
|
+
<ZoneNotice api={api} draftId={draftId} />
|
|
115
|
+
</>
|
|
116
|
+
) : (
|
|
117
|
+
<p className="basis-full text-xs text-muted-foreground">
|
|
118
|
+
A time that has already passed sends immediately.
|
|
119
|
+
</p>
|
|
120
|
+
)}
|
|
49
121
|
<Button type="submit" size="sm" disabled={pending || !when}>{submitLabel}</Button>
|
|
50
122
|
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Close</Button>
|
|
51
123
|
</form>
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
* Approved drafts waiting for their send time: when they go out, and the three
|
|
3
3
|
* things you can still do before then (reschedule, send now, cancel). Once the
|
|
4
4
|
* send starts the server refuses cancel and reschedule (409), shown as-is.
|
|
5
|
+
*
|
|
6
|
+
* A recipient-local draft ("9:00 in each recipient's own time zone") is listed
|
|
7
|
+
* too, and once its first zone has gone out it is "rolling": a per-zone progress
|
|
8
|
+
* table, and cancel / reschedule act on the zones that have not started only.
|
|
5
9
|
*/
|
|
6
10
|
import { useState, type ReactElement } from 'react';
|
|
7
11
|
import { Button } from '@startsimpli/ui';
|
|
@@ -15,9 +19,9 @@ import {
|
|
|
15
19
|
useDrafts,
|
|
16
20
|
useRescheduleDraft,
|
|
17
21
|
} from '../hooks/index';
|
|
18
|
-
import type { Draft } from '../types/index';
|
|
22
|
+
import type { Draft, DraftGroup } from '../types/index';
|
|
19
23
|
import { SchedulePicker } from './SchedulePicker';
|
|
20
|
-
import { ErrorNotice, formatDateTime } from './_shared';
|
|
24
|
+
import { ErrorNotice, Note, formatDateTime, formatLocalTime } from './_shared';
|
|
21
25
|
|
|
22
26
|
export interface ScheduledSendsProps {
|
|
23
27
|
api: CommsApi;
|
|
@@ -26,12 +30,58 @@ export interface ScheduledSendsProps {
|
|
|
26
30
|
pageSize?: number;
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
function groupCell(g: DraftGroup): string {
|
|
34
|
+
if (g.state === 'waiting') return 'Waiting';
|
|
35
|
+
if (g.state === 'sending') return g.late ? 'Sending (sent late: its time had already passed)' : 'Sending';
|
|
36
|
+
if (g.state === 'sent') return g.late ? 'Sent late (its time had already passed)' : 'Sent';
|
|
37
|
+
if (g.state === 'failed') return `Failed${g.failure ? `: ${g.failure.reason}` : ''}`;
|
|
38
|
+
if (g.state === 'expired') return `Expired: ${g.reason ?? 'comms were paused at its time'}`;
|
|
39
|
+
return 'Cancelled';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function GroupProgress({ groups }: { groups: DraftGroup[] }) {
|
|
43
|
+
return (
|
|
44
|
+
<table className="mt-2 w-full text-left text-sm" aria-label="Progress by time zone">
|
|
45
|
+
<thead>
|
|
46
|
+
<tr className="text-muted-foreground">
|
|
47
|
+
<th className="py-1 pr-3 font-medium">Time zone</th>
|
|
48
|
+
<th className="py-1 pr-3 font-medium">Goes out</th>
|
|
49
|
+
<th className="py-1 pr-3 font-medium">State</th>
|
|
50
|
+
<th className="py-1 font-medium">Sent</th>
|
|
51
|
+
</tr>
|
|
52
|
+
</thead>
|
|
53
|
+
<tbody>
|
|
54
|
+
{groups.map((g) => (
|
|
55
|
+
<tr key={g.zone} className="border-t">
|
|
56
|
+
<td className="py-1 pr-3">{g.zone}</td>
|
|
57
|
+
<td className="py-1 pr-3">
|
|
58
|
+
{formatDateTime(g.at, g.zone)}
|
|
59
|
+
{g.note === 'gap'
|
|
60
|
+
? ' (a clock change skips this time here, so it goes out at the first moment after the change)'
|
|
61
|
+
: g.note === 'overlap'
|
|
62
|
+
? ' (this time happens twice here; the first is used)'
|
|
63
|
+
: ''}
|
|
64
|
+
</td>
|
|
65
|
+
<td className="py-1 pr-3">{groupCell(g)}</td>
|
|
66
|
+
<td className="py-1">{`${g.sent} of ${g.count}`}</td>
|
|
67
|
+
</tr>
|
|
68
|
+
))}
|
|
69
|
+
</tbody>
|
|
70
|
+
</table>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
29
74
|
function ScheduledItem({ api, draft }: { api: CommsApi; draft: Draft }) {
|
|
30
75
|
const [moving, setMoving] = useState(false);
|
|
76
|
+
const [confirmingNow, setConfirmingNow] = useState(false);
|
|
31
77
|
const reschedule = useRescheduleDraft(api);
|
|
32
78
|
const cancel = useCancelDraft(api);
|
|
33
79
|
const sendNow = useApproveDraft(api);
|
|
34
80
|
const zone = draft.send_timezone || undefined;
|
|
81
|
+
const perZone = draft.send_mode === 'recipient_local';
|
|
82
|
+
const isRolling = draft.state === 'rolling' || draft.approval === 'rolling';
|
|
83
|
+
const groups = draft.groups ?? [];
|
|
84
|
+
const started = groups.filter((g) => g.state !== 'waiting').length;
|
|
35
85
|
const live = useDraftAudience(api, draft.id, true).data?.preview.count;
|
|
36
86
|
const frozen = draft.recipient_count;
|
|
37
87
|
const count =
|
|
@@ -45,25 +95,60 @@ function ScheduledItem({ api, draft }: { api: CommsApi; draft: Draft }) {
|
|
|
45
95
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
46
96
|
<span className="min-w-[10rem] flex-1 font-medium">{draft.subject || '(no subject)'}</span>
|
|
47
97
|
<span className="text-sm text-muted-foreground">
|
|
48
|
-
{
|
|
49
|
-
|
|
98
|
+
{perZone
|
|
99
|
+
? `Sends ${formatLocalTime(draft.local_time)} in each recipient's own time zone`
|
|
100
|
+
: `Sends ${formatDateTime(draft.send_at, zone)}`}
|
|
101
|
+
{perZone && !isRolling ? ` (first zone ${formatDateTime(draft.send_at)})` : ''}
|
|
102
|
+
{!perZone && zone && zone !== 'UTC' ? ` (${formatDateTime(draft.send_at, 'UTC')})` : ''}
|
|
50
103
|
</span>
|
|
51
104
|
<div className="flex flex-wrap gap-2">
|
|
52
105
|
<Button type="button" size="sm" variant="outline" onClick={() => setMoving((v) => !v)}>Reschedule</Button>
|
|
53
|
-
<Button type="button" size="sm" variant="outline" disabled={sendNow.isPending} onClick={() => sendNow.mutate(draft.id)}>
|
|
106
|
+
<Button type="button" size="sm" variant="outline" disabled={sendNow.isPending} onClick={() => (isRolling ? setConfirmingNow(true) : sendNow.mutate(draft.id))}>
|
|
54
107
|
Send now
|
|
55
108
|
</Button>
|
|
56
109
|
<Button type="button" size="sm" variant="ghost" disabled={cancel.isPending} onClick={() => cancel.mutate(draft.id)}>
|
|
57
|
-
Cancel schedule
|
|
110
|
+
{isRolling ? 'Cancel remaining zones' : 'Cancel schedule'}
|
|
58
111
|
</Button>
|
|
59
112
|
</div>
|
|
60
113
|
</div>
|
|
61
114
|
{count ? <p className="text-sm text-muted-foreground">{count}</p> : null}
|
|
115
|
+
{perZone ? (
|
|
116
|
+
<p className="text-sm text-muted-foreground">
|
|
117
|
+
People who join after the first zone starts are not included.
|
|
118
|
+
</p>
|
|
119
|
+
) : null}
|
|
120
|
+
{isRolling && confirmingNow ? (
|
|
121
|
+
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
122
|
+
<span>Send now starts every waiting time zone immediately.</span>
|
|
123
|
+
<Button
|
|
124
|
+
type="button"
|
|
125
|
+
size="sm"
|
|
126
|
+
disabled={sendNow.isPending}
|
|
127
|
+
onClick={() => sendNow.mutate(draft.id, { onSuccess: () => setConfirmingNow(false) })}
|
|
128
|
+
>
|
|
129
|
+
Yes, start every waiting zone
|
|
130
|
+
</Button>
|
|
131
|
+
<Button type="button" size="sm" variant="ghost" onClick={() => setConfirmingNow(false)}>Keep waiting</Button>
|
|
132
|
+
</div>
|
|
133
|
+
) : null}
|
|
134
|
+
{isRolling ? (
|
|
135
|
+
<>
|
|
136
|
+
<p className="text-sm text-muted-foreground">
|
|
137
|
+
{`${started} of ${groups.length} time zones started. Cancel stops the zones that have not started; zones already sent cannot be recalled.`}
|
|
138
|
+
</p>
|
|
139
|
+
{groups.length ? <GroupProgress groups={groups} /> : null}
|
|
140
|
+
{draft.notice ? <Note>{draft.notice.reason}</Note> : null}
|
|
141
|
+
</>
|
|
142
|
+
) : null}
|
|
62
143
|
{draft.dispatch_error ? (
|
|
63
144
|
<p className="text-sm text-muted-foreground">{`Trying again: ${draft.dispatch_error}`}</p>
|
|
64
145
|
) : null}
|
|
65
146
|
{moving ? (
|
|
66
147
|
<SchedulePicker
|
|
148
|
+
api={api}
|
|
149
|
+
draftId={draft.id}
|
|
150
|
+
initialMode={perZone ? 'recipient_local' : 'fixed'}
|
|
151
|
+
localOnly={isRolling}
|
|
67
152
|
submitLabel="Move send"
|
|
68
153
|
pending={reschedule.isPending}
|
|
69
154
|
onCancel={() => setMoving(false)}
|
|
@@ -78,17 +163,20 @@ function ScheduledItem({ api, draft }: { api: CommsApi; draft: Draft }) {
|
|
|
78
163
|
export function ScheduledSends({ api, pollIntervalMs = DEFAULT_POLL_MS, pageSize = 10 }: ScheduledSendsProps): ReactElement | null {
|
|
79
164
|
const [page, setPage] = useState(1);
|
|
80
165
|
const drafts = useDrafts(api, { status: 'scheduled', page, page_size: pageSize }, pollIntervalMs);
|
|
81
|
-
|
|
82
|
-
|
|
166
|
+
const rolling = useDrafts(api, { status: 'rolling', page: 1, page_size: 50 }, pollIntervalMs);
|
|
167
|
+
const error = drafts.error ?? rolling.error;
|
|
168
|
+
if (error) return <ErrorNotice error={error} />;
|
|
169
|
+
const rows = [...(rolling.data?.results ?? []), ...(drafts.data?.results ?? [])];
|
|
170
|
+
if (!rows.length) return null;
|
|
83
171
|
return (
|
|
84
172
|
<div className="space-y-1">
|
|
85
173
|
<h3 className="text-sm font-medium">Scheduled to send</h3>
|
|
86
174
|
<ul className="divide-y">
|
|
87
|
-
{
|
|
175
|
+
{rows.map((d) => (
|
|
88
176
|
<ScheduledItem key={d.id} api={api} draft={d} />
|
|
89
177
|
))}
|
|
90
178
|
</ul>
|
|
91
|
-
{drafts.data.count > pageSize ? (
|
|
179
|
+
{drafts.data && drafts.data.count > pageSize ? (
|
|
92
180
|
<div className="flex items-center gap-2 text-sm">
|
|
93
181
|
<Button type="button" size="sm" variant="outline" disabled={!drafts.data.previous} onClick={() => setPage(page - 1)}>
|
|
94
182
|
Previous
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import type { CommsApi } from '../api/comms';
|
|
19
19
|
import { useSends } from '../hooks/index';
|
|
20
20
|
import { COMEBACK_PROGRAM_KEY, PRODUCT_UPDATE_PROGRAM_KEY } from '../types/index';
|
|
21
|
-
import { ErrorNotice, NativeSelect, formatDate, formatDateTime, useFieldId } from './_shared';
|
|
21
|
+
import { ErrorNotice, NativeSelect, formatDate, formatDateTime, formatLocalTime, useFieldId } from './_shared';
|
|
22
22
|
|
|
23
23
|
export interface SendLogProps {
|
|
24
24
|
api: CommsApi;
|
|
@@ -93,7 +93,9 @@ export function SendLog({ api, pageSize = 25 }: SendLogProps): ReactElement {
|
|
|
93
93
|
<TableCell>{programLabel(s.program_key)}</TableCell>
|
|
94
94
|
<TableCell>{s.status}</TableCell>
|
|
95
95
|
<TableCell>
|
|
96
|
-
{s.status === 'scheduled'
|
|
96
|
+
{s.send_mode === 'recipient_local' && (s.status === 'scheduled' || s.status === 'rolling')
|
|
97
|
+
? `In each recipient's own time zone, from ${formatLocalTime(s.local_time)}`
|
|
98
|
+
: s.status === 'scheduled'
|
|
97
99
|
? formatDateTime(s.send_at, s.send_timezone)
|
|
98
100
|
: s.status === 'cancelled'
|
|
99
101
|
? `Cancelled ${formatDateTime(s.cancelled_at)}`
|
|
@@ -56,6 +56,14 @@ export function formatDateTime(iso: string | null | undefined, timeZone?: string
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** A wall-clock time with no zone ("2026-11-20T09:00:00"), shown as written: "Nov 20, 2026, 9:00 AM". */
|
|
60
|
+
export function formatLocalTime(local: string | null | undefined): string {
|
|
61
|
+
if (!local) return '';
|
|
62
|
+
const d = new Date(local);
|
|
63
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
64
|
+
return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
65
|
+
}
|
|
66
|
+
|
|
59
67
|
/** The viewer's IANA zone, or 'UTC' when the runtime cannot say. */
|
|
60
68
|
export function localTimeZone(): string {
|
|
61
69
|
try {
|
|
@@ -4,6 +4,7 @@ export * from './SequenceSettings';
|
|
|
4
4
|
export * from './EmailPreview';
|
|
5
5
|
export * from './ApprovalModeSetting';
|
|
6
6
|
export * from './VoiceSettings';
|
|
7
|
+
export * from './DefaultTimeZoneSetting';
|
|
7
8
|
export * from './SendingDomain';
|
|
8
9
|
export * from './ProductUpdates';
|
|
9
10
|
export * from './DraftReview';
|
|
@@ -22,6 +22,7 @@ import { DEFAULT_POLL_MS, useCommsSettings, usePrograms } from '../../hooks/inde
|
|
|
22
22
|
import { CommsApiError } from '../../types/index';
|
|
23
23
|
import { AnnouncementComposer } from '../AnnouncementComposer';
|
|
24
24
|
import { ApprovalModeSetting } from '../ApprovalModeSetting';
|
|
25
|
+
import { DefaultTimeZoneSetting } from '../DefaultTimeZoneSetting';
|
|
25
26
|
import { ProductUpdates } from '../ProductUpdates';
|
|
26
27
|
import { ProgramToggles } from '../ProgramToggles';
|
|
27
28
|
import { ResultsPanel } from '../ResultsPanel';
|
|
@@ -93,6 +94,9 @@ export function CustomerEmailsPage({
|
|
|
93
94
|
<Fold value="voice" title="Voice">
|
|
94
95
|
<VoiceSettings api={api} />
|
|
95
96
|
</Fold>
|
|
97
|
+
<Fold value="timezone" title="Time zone">
|
|
98
|
+
<DefaultTimeZoneSetting api={api} />
|
|
99
|
+
</Fold>
|
|
96
100
|
<Fold value="domain" title="Sending domain">
|
|
97
101
|
<SendingDomain api={api} />
|
|
98
102
|
</Fold>
|
package/src/comms/hooks/index.ts
CHANGED
|
@@ -281,8 +281,8 @@ export type ApproveDraftVars = string | ({ id: string } & Partial<ScheduleInput>
|
|
|
281
281
|
export function useApproveDraft(api: CommsApi): UseMutationResult<Draft, Error, ApproveDraftVars> {
|
|
282
282
|
return useScheduleWrite(api, (v: ApproveDraftVars) => {
|
|
283
283
|
if (typeof v === 'string') return api.approveDraft(v);
|
|
284
|
-
const { id, send_at,
|
|
285
|
-
return api.approveDraft(id, send_at ? { send_at,
|
|
284
|
+
const { id, send_at, ...rest } = v;
|
|
285
|
+
return api.approveDraft(id, send_at ? { send_at, ...rest } : undefined);
|
|
286
286
|
});
|
|
287
287
|
}
|
|
288
288
|
|
package/src/comms/types/index.ts
CHANGED
|
@@ -52,6 +52,11 @@ export interface CommsSettings {
|
|
|
52
52
|
domain_records: DomainRecord[];
|
|
53
53
|
domain_checked_at: string | null;
|
|
54
54
|
paused: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* IANA zone used for recipients with no known zone of their own when a send is
|
|
57
|
+
* timed per recipient. Blank = UTC.
|
|
58
|
+
*/
|
|
59
|
+
default_timezone: string;
|
|
55
60
|
source_repos: SourceRepo[];
|
|
56
61
|
/**
|
|
57
62
|
* The From address comms mail actually goes out with (UI gap G3): the
|
|
@@ -194,11 +199,11 @@ export interface ChangesResponse {
|
|
|
194
199
|
unclassified: string[];
|
|
195
200
|
}
|
|
196
201
|
|
|
197
|
-
export type DraftApproval = 'pending' | 'scheduled' | 'expired' | 'failed' | 'approved' | 'discarded';
|
|
202
|
+
export type DraftApproval = 'pending' | 'scheduled' | 'rolling' | 'expired' | 'failed' | 'approved' | 'discarded';
|
|
198
203
|
|
|
199
204
|
/** The named state of a draft/send (server `state`); every one has a reason and a next action. */
|
|
200
205
|
export type DraftState =
|
|
201
|
-
| 'generating' | 'writing_failed' | 'pending' | 'cancelled' | 'scheduled'
|
|
206
|
+
| 'generating' | 'writing_failed' | 'pending' | 'cancelled' | 'scheduled' | 'rolling'
|
|
202
207
|
| 'expired' | 'failed' | 'sending' | 'sent' | 'discarded';
|
|
203
208
|
|
|
204
209
|
export type DraftAction =
|
|
@@ -221,6 +226,42 @@ export interface DraftFailure {
|
|
|
221
226
|
at?: string;
|
|
222
227
|
}
|
|
223
228
|
|
|
229
|
+
/** One time zone's slice of a recipient-local send; counts come from the recipient rows. */
|
|
230
|
+
export type GroupState = 'waiting' | 'sending' | 'sent' | 'failed' | 'expired' | 'cancelled';
|
|
231
|
+
|
|
232
|
+
export interface DraftGroup {
|
|
233
|
+
zone: string;
|
|
234
|
+
/** UTC instant this zone's local time arrives. */
|
|
235
|
+
at: string;
|
|
236
|
+
state: GroupState;
|
|
237
|
+
/** Started well after `at` (the time had already passed when scheduled, or it was moved earlier). */
|
|
238
|
+
late: boolean;
|
|
239
|
+
/** 'gap': the local time is skipped by a clock change (it goes out at the first moment after the change); 'overlap': it happens twice (first one used). */
|
|
240
|
+
note: 'gap' | 'overlap' | null;
|
|
241
|
+
sources: { browser: number; foundry_default: number; utc: number };
|
|
242
|
+
count: number;
|
|
243
|
+
pending: number;
|
|
244
|
+
sent: number;
|
|
245
|
+
failed: number;
|
|
246
|
+
suppressed: number;
|
|
247
|
+
started_at: string | null;
|
|
248
|
+
completed_at: string | null;
|
|
249
|
+
failure: { stage: string; code: string; reason: string } | null;
|
|
250
|
+
reason: string | null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface GroupSummary {
|
|
254
|
+
total: number;
|
|
255
|
+
waiting: number;
|
|
256
|
+
sending: number;
|
|
257
|
+
sent: number;
|
|
258
|
+
failed: number;
|
|
259
|
+
expired: number;
|
|
260
|
+
cancelled: number;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export type SendMode = 'fixed' | 'recipient_local';
|
|
264
|
+
|
|
224
265
|
export interface Draft {
|
|
225
266
|
id: string;
|
|
226
267
|
program_key: string;
|
|
@@ -259,6 +300,13 @@ export interface Draft {
|
|
|
259
300
|
/** A scheduled send whose audience lookup failed and is being retried. */
|
|
260
301
|
dispatch_attempts?: number;
|
|
261
302
|
dispatch_error?: string | null;
|
|
303
|
+
/** 'recipient_local': `local_time` is a wall-clock time in each recipient's own zone. */
|
|
304
|
+
send_mode?: SendMode;
|
|
305
|
+
/** The wall-clock time (no offset) of a recipient-local send, e.g. 2026-11-20T09:00:00. */
|
|
306
|
+
local_time?: string | null;
|
|
307
|
+
/** Per-zone progress, soonest first; empty until the plan is frozen at the first zone's time. */
|
|
308
|
+
groups?: DraftGroup[];
|
|
309
|
+
group_summary?: GroupSummary | null;
|
|
262
310
|
}
|
|
263
311
|
|
|
264
312
|
/** A tenant-evaluated segment: the key (and params) the foundry's backend understands. */
|
|
@@ -295,6 +343,8 @@ export interface AudiencePreview {
|
|
|
295
343
|
excluded_total: number;
|
|
296
344
|
sample: string[];
|
|
297
345
|
excluded_sample: { email: string; reason: ExcludedReason }[];
|
|
346
|
+
/** Recipients with no known time zone of their own (a default or UTC applies to a per-zone send). */
|
|
347
|
+
no_timezone?: number;
|
|
298
348
|
}
|
|
299
349
|
|
|
300
350
|
export interface DraftAudience {
|
|
@@ -322,6 +372,11 @@ export interface AudienceSegments {
|
|
|
322
372
|
export interface ScheduleInput {
|
|
323
373
|
send_at: string;
|
|
324
374
|
timezone?: string;
|
|
375
|
+
/**
|
|
376
|
+
* 'recipient_local': `send_at` is a wall-clock time with no offset, sent at that
|
|
377
|
+
* time in each recipient's own zone; `timezone` must be absent.
|
|
378
|
+
*/
|
|
379
|
+
send_mode?: SendMode;
|
|
325
380
|
}
|
|
326
381
|
|
|
327
382
|
/** POST drafts/: either the email as written (201) or notes to write it from (202, generating). */
|
|
@@ -398,12 +453,16 @@ export interface ScheduledSend {
|
|
|
398
453
|
id: string;
|
|
399
454
|
program_key: string;
|
|
400
455
|
subject: string;
|
|
401
|
-
status: 'scheduled' | 'expired' | 'failed' | 'cancelled';
|
|
456
|
+
status: 'scheduled' | 'rolling' | 'expired' | 'failed' | 'cancelled';
|
|
402
457
|
/** Why: the failure, the pause that expired it, or who cancelled it. */
|
|
403
458
|
reason?: string;
|
|
404
459
|
send_at: string | null;
|
|
405
460
|
send_timezone: string | null;
|
|
406
461
|
cancelled_at: string | null;
|
|
462
|
+
send_mode?: SendMode;
|
|
463
|
+
local_time?: string | null;
|
|
464
|
+
groups?: DraftGroup[];
|
|
465
|
+
group_summary?: GroupSummary | null;
|
|
407
466
|
}
|
|
408
467
|
|
|
409
468
|
/** GET sends/: the recipient rows (paginated) plus the not-yet-sent drafts. */
|