@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.2.48",
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.162"
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/llm": "0.4.25",
68
- "@startsimpli/ui": "0.4.162"
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: a local date and time plus the time
3
- * zone it is meant in (default: the viewer's). The server stores the instant in
4
- * UTC; this form only collects the wall-clock time and the zone name.
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 { ScheduleInput } from '../types/index';
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
- export function SchedulePicker({ submitLabel, pending, onSubmit, onCancel }: SchedulePickerProps): ReactElement {
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) onSubmit({ send_at: when, timezone: zone });
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&apos;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
- <div className="space-y-1">
37
- <Label htmlFor={zoneId}>Time zone</Label>
38
- <div>
39
- <NativeSelect id={zoneId} value={zone} onChange={(e) => setZone(e.target.value)}>
40
- {timeZoneOptions().map((z) => (
41
- <option key={z} value={z}>{z}</option>
42
- ))}
43
- </NativeSelect>
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
- </div>
46
- <p className="basis-full text-xs text-muted-foreground">
47
- A time that has already passed sends immediately.
48
- </p>
107
+ )}
108
+ {local ? (
109
+ <>
110
+ <p className="basis-full text-xs text-muted-foreground">
111
+ Goes out at that time on each recipient&apos;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
- {`Sends ${formatDateTime(draft.send_at, zone)}`}
49
- {zone && zone !== 'UTC' ? ` (${formatDateTime(draft.send_at, 'UTC')})` : ''}
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
- if (drafts.error) return <ErrorNotice error={drafts.error} />;
82
- if (!drafts.data?.results.length) return null;
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
- {drafts.data.results.map((d) => (
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>
@@ -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, timezone } = v;
285
- return api.approveDraft(id, send_at ? { send_at, timezone } : undefined);
284
+ const { id, send_at, ...rest } = v;
285
+ return api.approveDraft(id, send_at ? { send_at, ...rest } : undefined);
286
286
  });
287
287
  }
288
288
 
@@ -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. */