@startsimpli/integrations 0.2.47 → 0.2.49

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.47",
3
+ "version": "0.2.49",
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.163"
39
39
  },
40
40
  "peerDependenciesMeta": {
41
41
  "react": {
@@ -65,7 +65,7 @@
65
65
  "typescript": "^6.0.0",
66
66
  "vitest": "^3.0.0",
67
67
  "@startsimpli/llm": "0.4.25",
68
- "@startsimpli/ui": "0.4.162"
68
+ "@startsimpli/ui": "0.4.163"
69
69
  },
70
70
  "scripts": {
71
71
  "type-check": "tsc --noEmit",
@@ -41,7 +41,8 @@ const server = setupServer(
41
41
  if (hit.path === '/programs/') return HttpResponse.json([comeback(), productUpdate()]);
42
42
  if (hit.path === '/drafts/' && hit.method === 'POST') return createReply();
43
43
  if (hit.path === '/drafts/') {
44
- const results = drafts.filter((d) => d.approval === 'pending');
44
+ const wanted = url.searchParams.get('status') ?? 'pending';
45
+ const results = drafts.filter((d) => d.approval === wanted);
45
46
  return HttpResponse.json({ count: results.length, next: null, previous: null, results });
46
47
  }
47
48
  const m = hit.path.match(/^\/drafts\/([^/]+)\/$/);
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Scheduled and cancellable sends (bd startsim-yzivy): schedule a pending draft,
3
+ * see it listed with its send time, reschedule / send now / cancel it, and see
4
+ * scheduled and cancelled drafts in the send log. A mocked Django (msw) behind
5
+ * the REAL createCommsApi client, so each test is click -> request -> render.
6
+ */
7
+ import * as React from 'react';
8
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
9
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
10
+ import userEvent from '@testing-library/user-event';
11
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
12
+ import { http, HttpResponse } from 'msw';
13
+ import { setupServer } from 'msw/node';
14
+
15
+ import { DraftReview } from '../components/DraftReview';
16
+ import { SendLog } from '../components/SendLog';
17
+ import { createCommsApi, setApiBase } from '../api/index';
18
+ import type { Draft, ScheduledSend } from '../types/index';
19
+ import { BASE, P, SLUG, draft } from './fixtures';
20
+
21
+ interface Hit { method: string; path: string; body: any }
22
+ let hits: Hit[] = [];
23
+ let drafts: Draft[] = [];
24
+ let scheduledReport: ScheduledSend[] = [];
25
+ let conflict = false;
26
+ let liveCount = 2;
27
+
28
+ const rel = (p: string) => p.replace(new URL(P).pathname, '');
29
+
30
+ const server = setupServer(
31
+ http.all(`${P}/*`, async ({ request }) => {
32
+ const url = new URL(request.url);
33
+ const text = request.method === 'GET' ? '' : await request.text();
34
+ const hit: Hit = { method: request.method, path: rel(url.pathname), body: text ? JSON.parse(text) : undefined };
35
+ hits.push(hit);
36
+ const { method, path, body } = hit;
37
+ let m: RegExpMatchArray | null;
38
+ if (path === '/drafts/' && method === 'GET') {
39
+ const wanted = url.searchParams.get('status');
40
+ const results = drafts.filter((d) => !wanted || d.approval === wanted);
41
+ return HttpResponse.json({ count: results.length, next: null, previous: null, results });
42
+ }
43
+ if ((m = path.match(/^\/drafts\/([\w-]+)\/(approve|reschedule|cancel)\/$/)) && method === 'POST') {
44
+ const d = drafts.find((x) => x.id === m![1])!;
45
+ if (conflict) return HttpResponse.json({ detail: 'the send has already started and cannot be cancelled' }, { status: 409 });
46
+ if (m[2] === 'approve') {
47
+ if (body?.send_at && body.send_at < '2026-01-01') Object.assign(d, { approval: 'approved', state: 'sending', sent_immediately: true, requested_send_at: '2020-01-01T00:00:00+00:00' });
48
+ else if (body?.send_at) Object.assign(d, { approval: 'scheduled', send_at: '2026-11-03T15:00:00+00:00', send_timezone: body.timezone ?? null });
49
+ else d.approval = 'approved';
50
+ } else if (m[2] === 'reschedule') {
51
+ Object.assign(d, { send_at: '2026-11-05T15:00:00+00:00', send_timezone: body.timezone ?? null });
52
+ } else {
53
+ Object.assign(d, { approval: 'pending', send_at: null, send_timezone: null, cancelled_at: '2026-11-01T09:00:00+00:00' });
54
+ }
55
+ return HttpResponse.json(d);
56
+ }
57
+ if ((m = path.match(/^\/drafts\/([\w-]+)\/retry\/$/)) && method === 'POST') {
58
+ const d = drafts.find((x) => x.id === m![1])!;
59
+ Object.assign(d, { approval: 'approved', state: 'sending', failure: null, next_actions: ['wait'] });
60
+ return HttpResponse.json(d);
61
+ }
62
+ if ((m = path.match(/^\/drafts\/([\w-]+)\/audience\/$/)) && method === 'GET') {
63
+ return HttpResponse.json({ audience: { base: 'all' }, is_default: true, preview: { count: liveCount, eligible: liveCount, excluded: {}, excluded_total: 0, sample: [], excluded_sample: [] } });
64
+ }
65
+ if (path === '/sends/' && method === 'GET') {
66
+ return HttpResponse.json({ count: 0, next: null, previous: null, results: [], scheduled: scheduledReport });
67
+ }
68
+ return HttpResponse.json({ detail: `unhandled ${method} ${path}` }, { status: 599 });
69
+ }),
70
+ );
71
+
72
+ beforeAll(() => {
73
+ server.listen({ onUnhandledRequest: 'error' });
74
+ setApiBase(`${BASE}/api/v1`);
75
+ });
76
+ afterEach(() => server.resetHandlers());
77
+ afterAll(() => server.close());
78
+ beforeEach(() => {
79
+ hits = [];
80
+ drafts = [draft()];
81
+ scheduledReport = [];
82
+ conflict = false;
83
+ liveCount = 2;
84
+ });
85
+
86
+ function renderWith(node: (api: ReturnType<typeof createCommsApi>) => React.ReactElement) {
87
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
88
+ return render(<QueryClientProvider client={qc}>{node(createCommsApi(SLUG))}</QueryClientProvider>);
89
+ }
90
+
91
+ const writes = () => hits.filter((h) => h.method !== 'GET');
92
+
93
+ async function schedule(when = '2026-11-03T10:00', zone = 'Asia/Tokyo') {
94
+ await userEvent.click(await screen.findByRole('button', { name: /schedule/i }));
95
+ fireEvent.change(screen.getByLabelText('Send at'), { target: { value: when } });
96
+ await userEvent.selectOptions(screen.getByLabelText('Time zone'), zone);
97
+ await userEvent.click(screen.getByRole('button', { name: /approve and schedule/i }));
98
+ }
99
+
100
+ describe('scheduling a pending draft', () => {
101
+ it('Approve and send still sends now, with no schedule on the wire', async () => {
102
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
103
+ await userEvent.click(await screen.findByRole('button', { name: /approve and send/i }));
104
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/drafts/d-1/approve/' }));
105
+ expect(writes()[0].body).toBeUndefined();
106
+ });
107
+
108
+ it('posts the local time with its zone, then lists the draft as scheduled', async () => {
109
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
110
+ await schedule();
111
+ await waitFor(() =>
112
+ expect(writes()[0]).toMatchObject({
113
+ method: 'POST', path: '/drafts/d-1/approve/', body: { send_at: '2026-11-03T10:00', timezone: 'Asia/Tokyo' },
114
+ }),
115
+ );
116
+ const heading = await screen.findByRole('heading', { name: /scheduled to send/i });
117
+ const list = heading.parentElement as HTMLElement;
118
+ expect(await within(list).findByText('New: stitch counter')).toBeInTheDocument();
119
+ expect(within(list).getByText(/^Sends /)).toBeInTheDocument();
120
+ // it left the waiting-for-approval list
121
+ expect(screen.queryByRole('button', { name: /approve and send/i })).toBeNull();
122
+ });
123
+
124
+ it('makes no write until the form is submitted', async () => {
125
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
126
+ await userEvent.click(await screen.findByRole('button', { name: /schedule/i }));
127
+ expect(screen.getByRole('button', { name: /approve and schedule/i })).toBeDisabled(); // no time yet
128
+ expect(writes()).toEqual([]);
129
+ });
130
+
131
+ it('shows the server reason when scheduling is refused', async () => {
132
+ conflict = true;
133
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
134
+ await schedule();
135
+ expect((await screen.findByRole('alert')).textContent).toMatch(/already started/i);
136
+ });
137
+ });
138
+
139
+ describe('a scheduled draft', () => {
140
+ beforeEach(() => {
141
+ drafts = [draft({ approval: 'scheduled', send_at: '2026-11-03T15:00:00+00:00', send_timezone: 'America/New_York' })];
142
+ });
143
+
144
+ it('is listed with its send time, in the zone it was set in', async () => {
145
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
146
+ expect(await screen.findByRole('heading', { name: /scheduled to send/i })).toBeInTheDocument();
147
+ expect(await screen.findByText(/^Sends .*(10:00|11:00)/)).toBeInTheDocument();
148
+ expect(screen.getByText('No updates are waiting for approval.')).toBeInTheDocument();
149
+ });
150
+
151
+ it('cancels the schedule and the draft is waiting for approval again', async () => {
152
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
153
+ await userEvent.click(await screen.findByRole('button', { name: /cancel schedule/i }));
154
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/drafts/d-1/cancel/' }));
155
+ expect(await screen.findByRole('button', { name: /approve and send/i })).toBeInTheDocument();
156
+ expect(await screen.findByText(/schedule cancelled/i)).toBeInTheDocument();
157
+ });
158
+
159
+ it('reschedules with a new time and zone', async () => {
160
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
161
+ await userEvent.click(await screen.findByRole('button', { name: /^reschedule$/i }));
162
+ fireEvent.change(screen.getByLabelText('Send at'), { target: { value: '2026-11-05T10:00' } });
163
+ await userEvent.selectOptions(screen.getByLabelText('Time zone'), 'UTC');
164
+ await userEvent.click(screen.getByRole('button', { name: /move send/i }));
165
+ await waitFor(() =>
166
+ expect(writes()[0]).toMatchObject({
167
+ method: 'POST', path: '/drafts/d-1/reschedule/', body: { send_at: '2026-11-05T10:00', timezone: 'UTC' },
168
+ }),
169
+ );
170
+ });
171
+
172
+ it('sends now', async () => {
173
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
174
+ await userEvent.click(await screen.findByRole('button', { name: /^send now$/i }));
175
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/drafts/d-1/approve/' }));
176
+ expect(writes()[0].body).toBeUndefined();
177
+ });
178
+
179
+ it('shows the server reason when cancel is too late', async () => {
180
+ conflict = true;
181
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
182
+ await userEvent.click(await screen.findByRole('button', { name: /cancel schedule/i }));
183
+ expect((await screen.findByRole('alert')).textContent).toMatch(/already started/i);
184
+ });
185
+ });
186
+
187
+ describe('send log', () => {
188
+ it('lists scheduled and cancelled drafts above the sent rows', async () => {
189
+ scheduledReport = [
190
+ { id: 's-1', program_key: 'product_update', subject: 'Tuesday news', status: 'scheduled',
191
+ send_at: '2026-11-03T15:00:00+00:00', send_timezone: 'UTC', cancelled_at: null },
192
+ { id: 's-2', program_key: 'announcement', subject: 'Dropped idea', status: 'cancelled',
193
+ send_at: null, send_timezone: null, cancelled_at: '2026-11-01T09:00:00+00:00' },
194
+ ];
195
+ renderWith((api) => <SendLog api={api} />);
196
+ const heading = await screen.findByRole('heading', { name: /scheduled and cancelled/i });
197
+ const table = within(heading.parentElement as HTMLElement);
198
+ expect(table.getByText('Tuesday news')).toBeInTheDocument();
199
+ expect(table.getByText('scheduled')).toBeInTheDocument();
200
+ expect(table.getByText('Dropped idea')).toBeInTheDocument();
201
+ expect(table.getByText('cancelled')).toBeInTheDocument();
202
+ expect(table.getByText(/^Cancelled /)).toBeInTheDocument();
203
+ });
204
+
205
+ it('shows no scheduled section when there is nothing scheduled', async () => {
206
+ renderWith((api) => <SendLog api={api} />);
207
+ expect(await screen.findByText(/no emails sent yet/i)).toBeInTheDocument();
208
+ expect(screen.queryByRole('heading', { name: /scheduled and cancelled/i })).toBeNull();
209
+ });
210
+ });
211
+
212
+ // ---- the closed loop: every state is named, with a reason and a next action ----
213
+
214
+ describe('states the admin has to act on', () => {
215
+ it('says when a past time was sent immediately', async () => {
216
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
217
+ await schedule('2020-01-01T10:00', 'UTC');
218
+ expect(await screen.findByText(/sent immediately/i)).toBeInTheDocument();
219
+ expect(screen.getByText(/had already passed/i)).toBeInTheDocument();
220
+ });
221
+
222
+ it('explains the picker: a time that has passed sends now', async () => {
223
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
224
+ await userEvent.click(await screen.findByRole('button', { name: /schedule/i }));
225
+ expect(screen.getByText(/already passed sends immediately/i)).toBeInTheDocument();
226
+ });
227
+
228
+ it('shows an expired send with its reason and original time, and can approve again', async () => {
229
+ drafts = [draft({
230
+ approval: 'expired', state: 'expired', next_actions: ['approve', 'schedule', 'edit', 'discard'],
231
+ notice: { code: 'expired', reason: 'comms were paused at the scheduled time', send_at: '2026-11-03T15:00:00+00:00' },
232
+ })];
233
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
234
+ const heading = await screen.findByRole('heading', { name: /needs attention/i });
235
+ const area = within(heading.parentElement as HTMLElement);
236
+ expect(await area.findByText(/comms were paused at the scheduled time/i)).toBeInTheDocument();
237
+ expect(area.getByText(/was scheduled for/i)).toBeInTheDocument();
238
+ await userEvent.click(area.getByRole('button', { name: /approve and send/i }));
239
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/drafts/d-1/approve/' }));
240
+ });
241
+
242
+ it('shows a failed send with the error and a Retry that posts retry', async () => {
243
+ drafts = [draft({
244
+ approval: 'failed', state: 'failed', next_actions: ['retry', 'approve', 'discard'],
245
+ failure: { stage: 'dispatch', code: 'audience_unavailable', reason: 'could not reach the foundry backend (after 3 attempts)', at: '2026-11-03T15:01:00+00:00' },
246
+ })];
247
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
248
+ expect(await screen.findByText(/could not reach the foundry backend/i)).toBeInTheDocument();
249
+ await userEvent.click(screen.getByRole('button', { name: /^retry$/i }));
250
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/drafts/d-1/retry/' }));
251
+ });
252
+
253
+ it('a send that failed after it started offers only Retry', async () => {
254
+ drafts = [draft({
255
+ approval: 'failed', state: 'failed', next_actions: ['retry'],
256
+ failure: { stage: 'send', code: 'recipients_not_sent', reason: '2 recipients were still unsent after 3 retries', at: '2026-11-03T15:01:00+00:00' },
257
+ })];
258
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
259
+ expect(await screen.findByText(/2 recipients were still unsent/i)).toBeInTheDocument();
260
+ expect(screen.getByRole('button', { name: /^retry$/i })).toBeInTheDocument();
261
+ expect(screen.queryByRole('button', { name: /approve and send/i })).toBeNull();
262
+ expect(screen.queryByRole('button', { name: /^discard$/i })).toBeNull();
263
+ });
264
+
265
+ it('shows why an auto-approved draft is still waiting', async () => {
266
+ drafts = [draft({ notice: { code: 'no_recipients', reason: 'this audience has no recipients (2 opted out)' } })];
267
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
268
+ expect(await screen.findByText(/no recipients \(2 opted out\)/i)).toBeInTheDocument();
269
+ });
270
+
271
+ it('shows a scheduled send that is being retried', async () => {
272
+ drafts = [draft({
273
+ approval: 'scheduled', send_at: '2026-11-03T15:00:00+00:00', send_timezone: 'UTC',
274
+ dispatch_attempts: 1, dispatch_error: 'could not reach the foundry backend',
275
+ })];
276
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
277
+ expect(await screen.findByText(/trying again.*could not reach the foundry backend/i)).toBeInTheDocument();
278
+ });
279
+
280
+ it('shows both recipient counts when the audience changed since scheduling', async () => {
281
+ liveCount = 5;
282
+ drafts = [draft({ approval: 'scheduled', send_at: '2026-11-03T15:00:00+00:00', send_timezone: 'UTC', recipient_count: 2 })];
283
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
284
+ expect(await screen.findByText(/2 recipients when scheduled, 5 now/i)).toBeInTheDocument();
285
+ });
286
+
287
+ it('shows one count when nothing changed', async () => {
288
+ drafts = [draft({ approval: 'scheduled', send_at: '2026-11-03T15:00:00+00:00', send_timezone: 'UTC', recipient_count: 2 })];
289
+ renderWith((api) => <DraftReview api={api} pollIntervalMs={10} />);
290
+ expect(await screen.findByText(/^2 recipients$/i)).toBeInTheDocument();
291
+ });
292
+
293
+ it('the send log gives the reason for expired and failed drafts', async () => {
294
+ scheduledReport = [
295
+ { id: 'e-1', program_key: 'announcement', subject: 'Paused one', status: 'expired', reason: 'comms were paused at the scheduled time',
296
+ send_at: '2026-11-03T15:00:00+00:00', send_timezone: null, cancelled_at: null },
297
+ { id: 'f-1', program_key: 'announcement', subject: 'Broken one', status: 'failed', reason: 'this audience has no recipients',
298
+ send_at: null, send_timezone: null, cancelled_at: null },
299
+ ];
300
+ renderWith((api) => <SendLog api={api} />);
301
+ expect(await screen.findByText('comms were paused at the scheduled time')).toBeInTheDocument();
302
+ expect(screen.getByText('this audience has no recipients')).toBeInTheDocument();
303
+ });
304
+ });
@@ -34,7 +34,8 @@ import type {
34
34
  ProgramUpdateResult,
35
35
  RenderedEmail,
36
36
  SendNowResult,
37
- SendRow,
37
+ SendsPage,
38
+ ScheduleInput,
38
39
  SendsQuery,
39
40
  StepGeneration,
40
41
  TestSendInput,
@@ -76,7 +77,14 @@ export interface CommsApi {
76
77
  uploadAsset(file: File, alt: string): Promise<CommsAsset>;
77
78
  deleteAsset(id: string): Promise<void>;
78
79
  updateDraft(id: string, patch: DraftPatch): Promise<Draft>;
79
- approveDraft(id: string): Promise<Draft>;
80
+ /** Without `schedule` the send is queued now; with it the draft is scheduled (status 'scheduled'). */
81
+ approveDraft(id: string, schedule?: ScheduleInput): Promise<Draft>;
82
+ /** Move a still-scheduled draft; 409 once the send has started. */
83
+ rescheduleDraft(id: string, schedule: ScheduleInput): Promise<Draft>;
84
+ /** Scheduled -> pending again; 409 once the send has started. */
85
+ cancelDraft(id: string): Promise<Draft>;
86
+ /** Resume a failed draft (re-run the dispatch, or re-queue the unsent recipients). */
87
+ retryDraft(id: string): Promise<Draft>;
80
88
  discardDraft(id: string): Promise<Draft>;
81
89
  previewDraft(id: string): Promise<RenderedEmail>;
82
90
  // per-send audience: the draft's own spec (or the program default) + who it reaches
@@ -87,7 +95,7 @@ export interface CommsApi {
87
95
  sendUpdateNow(input?: { notes?: string }): Promise<SendNowResult>;
88
96
 
89
97
  // results
90
- listSends(query?: SendsQuery): Promise<Paginated<SendRow>>;
98
+ listSends(query?: SendsQuery): Promise<SendsPage>;
91
99
  getMetrics(query?: MetricsQuery): Promise<CommsMetrics>;
92
100
  }
93
101
 
@@ -134,7 +142,11 @@ export function createCommsApi(slug: string): CommsApi {
134
142
  },
135
143
  deleteAsset: (id) => request(`${P}/assets/${seg(id)}/`, { method: 'DELETE' }),
136
144
  updateDraft: (id, patch) => request(draftPath(id), { method: 'PATCH', body: patch }),
137
- approveDraft: (id) => request(draftPath(id, 'approve'), { method: 'POST' }),
145
+ approveDraft: (id, schedule) =>
146
+ request(draftPath(id, 'approve'), schedule ? { method: 'POST', body: schedule } : { method: 'POST' }),
147
+ rescheduleDraft: (id, schedule) => request(draftPath(id, 'reschedule'), { method: 'POST', body: schedule }),
148
+ cancelDraft: (id) => request(draftPath(id, 'cancel'), { method: 'POST' }),
149
+ retryDraft: (id) => request(draftPath(id, 'retry'), { method: 'POST' }),
138
150
  discardDraft: (id) => request(draftPath(id, 'discard'), { method: 'POST' }),
139
151
  previewDraft: (id) => request(draftPath(id, 'preview')),
140
152
  getDraftAudience: (id) => request(draftPath(id, 'audience')),
@@ -14,15 +14,18 @@ import {
14
14
  DEFAULT_POLL_MS,
15
15
  useApproveDraft,
16
16
  useDiscardDraft,
17
+ useRetryDraft,
17
18
  useDraftPreview,
18
19
  useDrafts,
19
20
  useSendTest,
20
21
  useUpdateDraft,
21
22
  } from '../hooks/index';
22
- import type { Draft, DraftPatch } from '../types/index';
23
+ import type { Draft, DraftAction, DraftPatch } from '../types/index';
23
24
  import { AudiencePanel } from './AudiencePanel';
24
25
  import { EmailPreview } from './EmailPreview';
25
- import { ErrorNotice, Note, formatDate, humanize, useFieldId } from './_shared';
26
+ import { SchedulePicker } from './SchedulePicker';
27
+ import { ScheduledSends } from './ScheduledSends';
28
+ import { ErrorNotice, Note, formatDate, formatDateTime, humanize, useFieldId } from './_shared';
26
29
 
27
30
  export interface DraftReviewProps {
28
31
  api: CommsApi;
@@ -79,15 +82,36 @@ function DraftEditor({ api, draft, onDone }: { api: CommsApi; draft: Draft; onDo
79
82
  );
80
83
  }
81
84
 
82
- function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
85
+ /** The actions the server says are open; older servers send none, which means the pending set. */
86
+ function allowed(draft: Draft, action: DraftAction): boolean {
87
+ if (!draft.next_actions) return action !== 'retry';
88
+ return draft.next_actions.includes(action);
89
+ }
90
+
91
+ export function DraftItem({
92
+ api,
93
+ draft,
94
+ onSentImmediately,
95
+ }: {
96
+ api: CommsApi;
97
+ draft: Draft;
98
+ onSentImmediately?: (draft: Draft) => void;
99
+ }) {
83
100
  const [editing, setEditing] = useState(false);
84
101
  const [previewing, setPreviewing] = useState(false);
85
102
  const [auditing, setAuditing] = useState(false);
103
+ const [scheduling, setScheduling] = useState(false);
86
104
  const ready = !draft.generating && !draft.error;
87
105
  const preview = useDraftPreview(api, draft.id, previewing && ready);
88
106
  const sendTest = useSendTest(api);
89
107
  const approve = useApproveDraft(api);
90
108
  const discard = useDiscardDraft(api);
109
+ const retry = useRetryDraft(api);
110
+ const approveWith = (vars: Parameters<typeof approve.mutateAsync>[0], done?: () => void) =>
111
+ approve.mutateAsync(vars).then((d) => {
112
+ if (d.sent_immediately) onSentImmediately?.(d);
113
+ done?.();
114
+ }, () => undefined);
91
115
 
92
116
  return (
93
117
  <li className="py-3">
@@ -100,16 +124,20 @@ function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
100
124
  {ready ? (
101
125
  <>
102
126
  <Button type="button" size="sm" variant="outline" onClick={() => setPreviewing((v) => !v)}>Preview</Button>
103
- <Button type="button" size="sm" variant="outline" onClick={() => setEditing((v) => !v)}>Edit</Button>
104
- <Button
105
- type="button"
106
- size="sm"
107
- variant="outline"
108
- aria-expanded={auditing}
109
- onClick={() => setAuditing((v) => !v)}
110
- >
111
- Audience
112
- </Button>
127
+ {allowed(draft, 'edit') ? (
128
+ <>
129
+ <Button type="button" size="sm" variant="outline" onClick={() => setEditing((v) => !v)}>Edit</Button>
130
+ <Button
131
+ type="button"
132
+ size="sm"
133
+ variant="outline"
134
+ aria-expanded={auditing}
135
+ onClick={() => setAuditing((v) => !v)}
136
+ >
137
+ Audience
138
+ </Button>
139
+ </>
140
+ ) : null}
113
141
  <Button
114
142
  type="button"
115
143
  size="sm"
@@ -119,12 +147,24 @@ function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
119
147
  >
120
148
  Send test to me
121
149
  </Button>
122
- <Button type="button" size="sm" disabled={approve.isPending} onClick={() => approve.mutate(draft.id)}>
123
- Approve and send
124
- </Button>
150
+ {allowed(draft, 'approve') ? (
151
+ <Button type="button" size="sm" disabled={approve.isPending} onClick={() => approveWith(draft.id)}>
152
+ Approve and send
153
+ </Button>
154
+ ) : null}
155
+ {allowed(draft, 'schedule') ? (
156
+ <Button type="button" size="sm" variant="outline" onClick={() => setScheduling((v) => !v)}>
157
+ Schedule…
158
+ </Button>
159
+ ) : null}
160
+ {allowed(draft, 'retry') ? (
161
+ <Button type="button" size="sm" disabled={retry.isPending} onClick={() => retry.mutate(draft.id)}>
162
+ Retry
163
+ </Button>
164
+ ) : null}
125
165
  </>
126
166
  ) : null}
127
- {!draft.generating ? (
167
+ {!draft.generating && allowed(draft, 'discard') ? (
128
168
  <Button type="button" size="sm" variant="ghost" disabled={discard.isPending} onClick={() => discard.mutate(draft.id)}>
129
169
  Discard
130
170
  </Button>
@@ -136,39 +176,100 @@ function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
136
176
  {sendTest.data && !sendTest.data.sent ? (
137
177
  <ErrorNotice error={new Error(`Test not sent: ${humanize(sendTest.data.reason || 'refused')}`)} />
138
178
  ) : null}
139
- <ErrorNotice error={sendTest.error ?? approve.error ?? discard.error ?? preview.error} />
179
+ {draft.failure ? <ErrorNotice error={new Error(`Send failed: ${draft.failure.reason}`)} /> : null}
180
+ {draft.notice ? (
181
+ <Note>
182
+ {draft.notice.code === 'expired' && draft.notice.send_at
183
+ ? `${draft.notice.reason}. It was scheduled for ${formatDateTime(draft.notice.send_at)}.`
184
+ : draft.notice.reason}
185
+ </Note>
186
+ ) : null}
187
+ <ErrorNotice error={sendTest.error ?? approve.error ?? discard.error ?? retry.error ?? preview.error} />
140
188
  {editing && ready ? <DraftEditor api={api} draft={draft} onDone={() => setEditing(false)} /> : null}
141
189
  {auditing && ready ? <AudiencePanel api={api} draftId={draft.id} /> : null}
142
190
  {previewing && preview.data ? <EmailPreview email={preview.data} /> : null}
191
+ {scheduling && ready ? (
192
+ <SchedulePicker
193
+ submitLabel="Approve and schedule"
194
+ pending={approve.isPending}
195
+ onCancel={() => setScheduling(false)}
196
+ onSubmit={(s) => approveWith({ id: draft.id, ...s }, () => setScheduling(false))}
197
+ />
198
+ ) : null}
199
+ {draft.cancelled_at ? <Note>{`Schedule cancelled ${formatDateTime(draft.cancelled_at)}.`}</Note> : null}
143
200
  </li>
144
201
  );
145
202
  }
146
203
 
204
+ /** Expired and failed drafts: each states why and offers its next action. */
205
+ function NeedsAttention({ api, pollIntervalMs, onSentImmediately }: {
206
+ api: CommsApi;
207
+ pollIntervalMs: number;
208
+ onSentImmediately: (draft: Draft) => void;
209
+ }): ReactElement | null {
210
+ const expired = useDrafts(api, { status: 'expired', page: 1, page_size: 50 }, pollIntervalMs);
211
+ const failed = useDrafts(api, { status: 'failed', page: 1, page_size: 50 }, pollIntervalMs);
212
+ const rows = [...(failed.data?.results ?? []), ...(expired.data?.results ?? [])];
213
+ if (!rows.length) return null;
214
+ return (
215
+ <div className="space-y-1">
216
+ <h3 className="text-sm font-medium">Needs attention</h3>
217
+ <ul className="divide-y">
218
+ {rows.map((d) => (
219
+ <DraftItem key={d.id} api={api} draft={d} onSentImmediately={onSentImmediately} />
220
+ ))}
221
+ </ul>
222
+ </div>
223
+ );
224
+ }
225
+
147
226
  export function DraftReview({ api, pollIntervalMs = DEFAULT_POLL_MS, pageSize = 10 }: DraftReviewProps): ReactElement | null {
148
227
  const [page, setPage] = useState(1);
228
+ const [justSent, setJustSent] = useState<Draft | null>(null);
149
229
  const drafts = useDrafts(api, { status: 'pending', page, page_size: pageSize }, pollIntervalMs);
150
230
  if (drafts.error) return <ErrorNotice error={drafts.error} />;
151
231
  if (!drafts.data) return null;
152
232
  const { results, count } = drafts.data;
153
- if (!results.length) return <Note>No updates are waiting for approval.</Note>;
154
- return (
155
- <div className="space-y-1">
156
- <h3 className="text-sm font-medium">Waiting for approval</h3>
157
- <ul className="divide-y">
158
- {results.map((d) => (
159
- <DraftItem key={d.id} api={api} draft={d} />
160
- ))}
161
- </ul>
162
- {count > pageSize ? (
163
- <div className="flex items-center gap-2 text-sm">
164
- <Button type="button" size="sm" variant="outline" disabled={!drafts.data.previous} onClick={() => setPage(page - 1)}>
165
- Previous
166
- </Button>
167
- <Button type="button" size="sm" variant="outline" disabled={!drafts.data.next} onClick={() => setPage(page + 1)}>
168
- Next
169
- </Button>
170
- </div>
233
+ const extras = (
234
+ <>
235
+ {justSent ? (
236
+ <Note>
237
+ {`"${justSent.subject}" was sent immediately: the requested time (${formatDateTime(justSent.requested_send_at)}) had already passed.`}
238
+ </Note>
171
239
  ) : null}
172
- </div>
240
+ <NeedsAttention api={api} pollIntervalMs={pollIntervalMs} onSentImmediately={setJustSent} />
241
+ <ScheduledSends api={api} pollIntervalMs={pollIntervalMs} />
242
+ </>
243
+ );
244
+ if (!results.length) {
245
+ return (
246
+ <>
247
+ <Note>No updates are waiting for approval.</Note>
248
+ {extras}
249
+ </>
250
+ );
251
+ }
252
+ return (
253
+ <>
254
+ <div className="space-y-1">
255
+ <h3 className="text-sm font-medium">Waiting for approval</h3>
256
+ <ul className="divide-y">
257
+ {results.map((d) => (
258
+ <DraftItem key={d.id} api={api} draft={d} onSentImmediately={setJustSent} />
259
+ ))}
260
+ </ul>
261
+ {count > pageSize ? (
262
+ <div className="flex items-center gap-2 text-sm">
263
+ <Button type="button" size="sm" variant="outline" disabled={!drafts.data.previous} onClick={() => setPage(page - 1)}>
264
+ Previous
265
+ </Button>
266
+ <Button type="button" size="sm" variant="outline" disabled={!drafts.data.next} onClick={() => setPage(page + 1)}>
267
+ Next
268
+ </Button>
269
+ </div>
270
+ ) : null}
271
+ </div>
272
+ {extras}
273
+ </>
173
274
  );
174
275
  }
@@ -0,0 +1,53 @@
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.
5
+ */
6
+ import { useState, type ReactElement } from 'react';
7
+ import { Button, Input, Label } from '@startsimpli/ui';
8
+
9
+ import type { ScheduleInput } from '../types/index';
10
+ import { NativeSelect, localTimeZone, timeZoneOptions, useFieldId } from './_shared';
11
+
12
+ export interface SchedulePickerProps {
13
+ submitLabel: string;
14
+ pending?: boolean;
15
+ onSubmit: (schedule: ScheduleInput) => void;
16
+ onCancel: () => void;
17
+ }
18
+
19
+ export function SchedulePicker({ submitLabel, pending, onSubmit, onCancel }: SchedulePickerProps): ReactElement {
20
+ const [when, setWhen] = useState('');
21
+ const [zone, setZone] = useState(localTimeZone);
22
+ const whenId = useFieldId('comms-send-at');
23
+ const zoneId = useFieldId('comms-send-zone');
24
+ return (
25
+ <form
26
+ className="mt-2 flex flex-wrap items-end gap-3 rounded-md border p-3"
27
+ onSubmit={(e) => {
28
+ e.preventDefault();
29
+ if (when) onSubmit({ send_at: when, timezone: zone });
30
+ }}
31
+ >
32
+ <div className="space-y-1">
33
+ <Label htmlFor={whenId}>Send at</Label>
34
+ <Input id={whenId} type="datetime-local" required value={when} onChange={(e) => setWhen(e.target.value)} />
35
+ </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>
44
+ </div>
45
+ </div>
46
+ <p className="basis-full text-xs text-muted-foreground">
47
+ A time that has already passed sends immediately.
48
+ </p>
49
+ <Button type="submit" size="sm" disabled={pending || !when}>{submitLabel}</Button>
50
+ <Button type="button" size="sm" variant="ghost" onClick={onCancel}>Close</Button>
51
+ </form>
52
+ );
53
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Approved drafts waiting for their send time: when they go out, and the three
3
+ * things you can still do before then (reschedule, send now, cancel). Once the
4
+ * send starts the server refuses cancel and reschedule (409), shown as-is.
5
+ */
6
+ import { useState, type ReactElement } from 'react';
7
+ import { Button } from '@startsimpli/ui';
8
+
9
+ import type { CommsApi } from '../api/comms';
10
+ import {
11
+ DEFAULT_POLL_MS,
12
+ useApproveDraft,
13
+ useCancelDraft,
14
+ useDraftAudience,
15
+ useDrafts,
16
+ useRescheduleDraft,
17
+ } from '../hooks/index';
18
+ import type { Draft } from '../types/index';
19
+ import { SchedulePicker } from './SchedulePicker';
20
+ import { ErrorNotice, formatDateTime } from './_shared';
21
+
22
+ export interface ScheduledSendsProps {
23
+ api: CommsApi;
24
+ pollIntervalMs?: number;
25
+ /** Drafts per server page. Default 10. */
26
+ pageSize?: number;
27
+ }
28
+
29
+ function ScheduledItem({ api, draft }: { api: CommsApi; draft: Draft }) {
30
+ const [moving, setMoving] = useState(false);
31
+ const reschedule = useRescheduleDraft(api);
32
+ const cancel = useCancelDraft(api);
33
+ const sendNow = useApproveDraft(api);
34
+ const zone = draft.send_timezone || undefined;
35
+ const live = useDraftAudience(api, draft.id, true).data?.preview.count;
36
+ const frozen = draft.recipient_count;
37
+ const count =
38
+ live === undefined
39
+ ? null
40
+ : frozen != null && frozen !== live
41
+ ? `${frozen} recipients when scheduled, ${live} now`
42
+ : `${live} recipients`;
43
+ return (
44
+ <li className="py-3">
45
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
46
+ <span className="min-w-[10rem] flex-1 font-medium">{draft.subject || '(no subject)'}</span>
47
+ <span className="text-sm text-muted-foreground">
48
+ {`Sends ${formatDateTime(draft.send_at, zone)}`}
49
+ {zone && zone !== 'UTC' ? ` (${formatDateTime(draft.send_at, 'UTC')})` : ''}
50
+ </span>
51
+ <div className="flex flex-wrap gap-2">
52
+ <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)}>
54
+ Send now
55
+ </Button>
56
+ <Button type="button" size="sm" variant="ghost" disabled={cancel.isPending} onClick={() => cancel.mutate(draft.id)}>
57
+ Cancel schedule
58
+ </Button>
59
+ </div>
60
+ </div>
61
+ {count ? <p className="text-sm text-muted-foreground">{count}</p> : null}
62
+ {draft.dispatch_error ? (
63
+ <p className="text-sm text-muted-foreground">{`Trying again: ${draft.dispatch_error}`}</p>
64
+ ) : null}
65
+ {moving ? (
66
+ <SchedulePicker
67
+ submitLabel="Move send"
68
+ pending={reschedule.isPending}
69
+ onCancel={() => setMoving(false)}
70
+ onSubmit={(s) => reschedule.mutate({ id: draft.id, ...s }, { onSuccess: () => setMoving(false) })}
71
+ />
72
+ ) : null}
73
+ <ErrorNotice error={reschedule.error ?? cancel.error ?? sendNow.error} />
74
+ </li>
75
+ );
76
+ }
77
+
78
+ export function ScheduledSends({ api, pollIntervalMs = DEFAULT_POLL_MS, pageSize = 10 }: ScheduledSendsProps): ReactElement | null {
79
+ const [page, setPage] = useState(1);
80
+ 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;
83
+ return (
84
+ <div className="space-y-1">
85
+ <h3 className="text-sm font-medium">Scheduled to send</h3>
86
+ <ul className="divide-y">
87
+ {drafts.data.results.map((d) => (
88
+ <ScheduledItem key={d.id} api={api} draft={d} />
89
+ ))}
90
+ </ul>
91
+ {drafts.data.count > pageSize ? (
92
+ <div className="flex items-center gap-2 text-sm">
93
+ <Button type="button" size="sm" variant="outline" disabled={!drafts.data.previous} onClick={() => setPage(page - 1)}>
94
+ Previous
95
+ </Button>
96
+ <Button type="button" size="sm" variant="outline" disabled={!drafts.data.next} onClick={() => setPage(page + 1)}>
97
+ Next
98
+ </Button>
99
+ </div>
100
+ ) : null}
101
+ </div>
102
+ );
103
+ }
@@ -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, useFieldId } from './_shared';
21
+ import { ErrorNotice, NativeSelect, formatDate, formatDateTime, useFieldId } from './_shared';
22
22
 
23
23
  export interface SendLogProps {
24
24
  api: CommsApi;
@@ -73,6 +73,41 @@ export function SendLog({ api, pageSize = 25 }: SendLogProps): ReactElement {
73
73
  </div>
74
74
  </div>
75
75
  <ErrorNotice error={sends.error} />
76
+ {data?.scheduled?.length ? (
77
+ <div className="space-y-1">
78
+ <h3 className="text-sm font-medium">Scheduled and cancelled</h3>
79
+ <Table>
80
+ <TableHeader>
81
+ <TableRow>
82
+ <TableHead>Subject</TableHead>
83
+ <TableHead>Program</TableHead>
84
+ <TableHead>Status</TableHead>
85
+ <TableHead>Goes out</TableHead>
86
+ <TableHead>Reason</TableHead>
87
+ </TableRow>
88
+ </TableHeader>
89
+ <TableBody>
90
+ {data.scheduled.map((s) => (
91
+ <TableRow key={s.id}>
92
+ <TableCell>{s.subject}</TableCell>
93
+ <TableCell>{programLabel(s.program_key)}</TableCell>
94
+ <TableCell>{s.status}</TableCell>
95
+ <TableCell>
96
+ {s.status === 'scheduled'
97
+ ? formatDateTime(s.send_at, s.send_timezone)
98
+ : s.status === 'cancelled'
99
+ ? `Cancelled ${formatDateTime(s.cancelled_at)}`
100
+ : s.send_at
101
+ ? `Was ${formatDateTime(s.send_at, s.send_timezone)}`
102
+ : ''}
103
+ </TableCell>
104
+ <TableCell>{s.reason}</TableCell>
105
+ </TableRow>
106
+ ))}
107
+ </TableBody>
108
+ </Table>
109
+ </div>
110
+ ) : null}
76
111
  <Table>
77
112
  <TableHeader>
78
113
  <TableRow>
@@ -41,6 +41,41 @@ export function formatDate(iso: string | null | undefined): string {
41
41
  return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
42
42
  }
43
43
 
44
+ /** "Nov 3, 2026, 10:00 AM EST" in `timeZone` (IANA), or the viewer's zone when absent or unknown. */
45
+ export function formatDateTime(iso: string | null | undefined, timeZone?: string | null): string {
46
+ if (!iso) return '';
47
+ const d = new Date(iso);
48
+ if (Number.isNaN(d.getTime())) return '';
49
+ const opts: Intl.DateTimeFormatOptions = {
50
+ year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short',
51
+ };
52
+ try {
53
+ return d.toLocaleString(undefined, timeZone ? { ...opts, timeZone } : opts);
54
+ } catch {
55
+ return d.toLocaleString(undefined, opts);
56
+ }
57
+ }
58
+
59
+ /** The viewer's IANA zone, or 'UTC' when the runtime cannot say. */
60
+ export function localTimeZone(): string {
61
+ try {
62
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
63
+ } catch {
64
+ return 'UTC';
65
+ }
66
+ }
67
+
68
+ /** IANA zones for a picker: the runtime's list when it has one, always with the viewer's zone and UTC. */
69
+ export function timeZoneOptions(): string[] {
70
+ let zones: string[] = [];
71
+ try {
72
+ zones = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf?.('timeZone') ?? [];
73
+ } catch {
74
+ zones = [];
75
+ }
76
+ return Array.from(new Set([localTimeZone(), 'UTC', ...zones]));
77
+ }
78
+
44
79
  /** A readable reason code: "not_allowlisted" -> "not allowlisted". */
45
80
  export function humanize(code: string): string {
46
81
  return code.replace(/_/g, ' ');
@@ -7,6 +7,8 @@ export * from './VoiceSettings';
7
7
  export * from './SendingDomain';
8
8
  export * from './ProductUpdates';
9
9
  export * from './DraftReview';
10
+ export * from './SchedulePicker';
11
+ export * from './ScheduledSends';
10
12
  export * from './ResultsPanel';
11
13
  export * from './SendLog';
12
14
  export * from './AudiencePanel';
@@ -38,7 +38,8 @@ import type {
38
38
  ProgramUpdateResult,
39
39
  RenderedEmail,
40
40
  SendNowResult,
41
- SendRow,
41
+ ScheduleInput,
42
+ SendsPage,
42
43
  SendsQuery,
43
44
  StepGeneration,
44
45
  TestSendInput,
@@ -274,8 +275,39 @@ export function useUpdateDraft(api: CommsApi): UseMutationResult<Draft, Error, {
274
275
  });
275
276
  }
276
277
 
277
- export function useApproveDraft(api: CommsApi): UseMutationResult<Draft, Error, string> {
278
- return useDraftsWrite(api, (id: string) => api.approveDraft(id));
278
+ /** A draft id to send now, or {id, ...schedule} to schedule it. */
279
+ export type ApproveDraftVars = string | ({ id: string } & Partial<ScheduleInput>);
280
+
281
+ export function useApproveDraft(api: CommsApi): UseMutationResult<Draft, Error, ApproveDraftVars> {
282
+ return useScheduleWrite(api, (v: ApproveDraftVars) => {
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);
286
+ });
287
+ }
288
+
289
+ /** Drafts and the sends report both change when a schedule does. */
290
+ function useScheduleWrite<R, V>(api: CommsApi, fn: (v: V) => Promise<R>) {
291
+ const qc = useQueryClient();
292
+ return useMutation<R, Error, V>({
293
+ mutationFn: fn,
294
+ onSuccess: () => Promise.all([
295
+ qc.invalidateQueries({ queryKey: commsKeys.draftsAll(api.slug) }),
296
+ qc.invalidateQueries({ queryKey: ['startsimpli', 'comms', api.slug, 'sends'] }),
297
+ ]),
298
+ });
299
+ }
300
+
301
+ export function useRescheduleDraft(api: CommsApi): UseMutationResult<Draft, Error, { id: string } & ScheduleInput> {
302
+ return useScheduleWrite(api, ({ id, ...schedule }: { id: string } & ScheduleInput) => api.rescheduleDraft(id, schedule));
303
+ }
304
+
305
+ export function useRetryDraft(api: CommsApi): UseMutationResult<Draft, Error, string> {
306
+ return useScheduleWrite(api, (id: string) => api.retryDraft(id));
307
+ }
308
+
309
+ export function useCancelDraft(api: CommsApi): UseMutationResult<Draft, Error, string> {
310
+ return useScheduleWrite(api, (id: string) => api.cancelDraft(id));
279
311
  }
280
312
 
281
313
  export function useDiscardDraft(api: CommsApi): UseMutationResult<Draft, Error, string> {
@@ -312,7 +344,7 @@ export function useDeleteAsset(api: CommsApi): UseMutationResult<void, Error, st
312
344
  // results ------------------------------------------------------------------
313
345
 
314
346
  /** Server-side pagination: page/page_size go to the server, never sliced here. */
315
- export function useSends(api: CommsApi, q: SendsQuery): UseQueryResult<Paginated<SendRow>> {
347
+ export function useSends(api: CommsApi, q: SendsQuery): UseQueryResult<SendsPage> {
316
348
  return useQuery({
317
349
  queryKey: commsKeys.sends(api.slug, q),
318
350
  queryFn: () => api.listSends(q),
@@ -194,7 +194,32 @@ export interface ChangesResponse {
194
194
  unclassified: string[];
195
195
  }
196
196
 
197
- export type DraftApproval = 'pending' | 'approved' | 'discarded';
197
+ export type DraftApproval = 'pending' | 'scheduled' | 'expired' | 'failed' | 'approved' | 'discarded';
198
+
199
+ /** The named state of a draft/send (server `state`); every one has a reason and a next action. */
200
+ export type DraftState =
201
+ | 'generating' | 'writing_failed' | 'pending' | 'cancelled' | 'scheduled'
202
+ | 'expired' | 'failed' | 'sending' | 'sent' | 'discarded';
203
+
204
+ export type DraftAction =
205
+ | 'approve' | 'schedule' | 'edit' | 'discard' | 'reschedule' | 'send_now' | 'cancel' | 'retry' | 'wait';
206
+
207
+ /** Why a pending or expired draft needs a person (no recipients, paused at the scheduled time...). */
208
+ export interface DraftNotice {
209
+ code: string;
210
+ reason: string;
211
+ /** The original send time, on an expired draft. */
212
+ send_at?: string | null;
213
+ at?: string;
214
+ }
215
+
216
+ /** Why a draft failed, and where: dispatch (before any recipient was queued), queue, or send. */
217
+ export interface DraftFailure {
218
+ stage: 'dispatch' | 'queue' | 'send' | string;
219
+ code: string;
220
+ reason: string;
221
+ at?: string;
222
+ }
198
223
 
199
224
  export interface Draft {
200
225
  id: string;
@@ -216,6 +241,24 @@ export interface Draft {
216
241
  audience_is_default?: boolean;
217
242
  /** Recipients queued, set at approve. */
218
243
  recipient_count?: number | null;
244
+ /** UTC instant a scheduled draft goes out (approval 'scheduled'; kept after it starts). */
245
+ send_at?: string | null;
246
+ /** IANA zone the admin typed send_at in; display only, never changes when it sends. */
247
+ send_timezone?: string | null;
248
+ /** Set on a pending draft whose schedule was cancelled. */
249
+ cancelled_at?: string | null;
250
+ state?: DraftState;
251
+ /** What the admin can do next; absent on older servers (treated as the pending set). */
252
+ next_actions?: DraftAction[];
253
+ notice?: DraftNotice | null;
254
+ failure?: DraftFailure | null;
255
+ /** The requested send_at was already past, so it was sent right away. */
256
+ sent_immediately?: boolean;
257
+ requested_send_at?: string | null;
258
+ completed_at?: string | null;
259
+ /** A scheduled send whose audience lookup failed and is being retried. */
260
+ dispatch_attempts?: number;
261
+ dispatch_error?: string | null;
219
262
  }
220
263
 
221
264
  /** A tenant-evaluated segment: the key (and params) the foundry's backend understands. */
@@ -271,6 +314,16 @@ export interface AudienceSegments {
271
314
  segments: AudienceSegment[];
272
315
  }
273
316
 
317
+ /**
318
+ * When to send an approved draft. `send_at` is ISO 8601: with an offset or Z it
319
+ * is an exact instant; a local time ("2026-11-03T10:00") needs `timezone` (IANA).
320
+ * Stored and dispatched in UTC by the server.
321
+ */
322
+ export interface ScheduleInput {
323
+ send_at: string;
324
+ timezone?: string;
325
+ }
326
+
274
327
  /** POST drafts/: either the email as written (201) or notes to write it from (202, generating). */
275
328
  export type DraftCreate =
276
329
  | { subject: string; html: string; preheader?: string; text?: string }
@@ -340,6 +393,22 @@ export interface SendRow {
340
393
  error: string | null;
341
394
  }
342
395
 
396
+ /** A draft waiting for its send_at, or one whose schedule was cancelled (sends report). */
397
+ export interface ScheduledSend {
398
+ id: string;
399
+ program_key: string;
400
+ subject: string;
401
+ status: 'scheduled' | 'expired' | 'failed' | 'cancelled';
402
+ /** Why: the failure, the pause that expired it, or who cancelled it. */
403
+ reason?: string;
404
+ send_at: string | null;
405
+ send_timezone: string | null;
406
+ cancelled_at: string | null;
407
+ }
408
+
409
+ /** GET sends/: the recipient rows (paginated) plus the not-yet-sent drafts. */
410
+ export type SendsPage = Paginated<SendRow> & { scheduled?: ScheduledSend[] };
411
+
343
412
  export interface SendsQuery extends PageParams {
344
413
  program?: string;
345
414
  status?: string;