@aglyn/shared-ui-email-campaigns 1.0.0-beta.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +73 -0
  3. package/package.json +39 -0
  4. package/src/index.d.ts +17 -0
  5. package/src/index.js +23 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/campaign-picker.component.d.ts +54 -0
  8. package/src/lib/components/campaign-picker.component.js +121 -0
  9. package/src/lib/components/campaign-picker.component.js.map +1 -0
  10. package/src/lib/components/report-figures.d.ts +51 -0
  11. package/src/lib/components/report-figures.js +120 -0
  12. package/src/lib/components/report-figures.js.map +1 -0
  13. package/src/lib/model/campaign-container.d.ts +359 -0
  14. package/src/lib/model/campaign-container.js +355 -0
  15. package/src/lib/model/campaign-container.js.map +1 -0
  16. package/src/lib/model/campaign-conversions.d.ts +286 -0
  17. package/src/lib/model/campaign-conversions.js +249 -0
  18. package/src/lib/model/campaign-conversions.js.map +1 -0
  19. package/src/lib/model/campaign-report.d.ts +304 -0
  20. package/src/lib/model/campaign-report.js +326 -0
  21. package/src/lib/model/campaign-report.js.map +1 -0
  22. package/src/lib/model/campaign-revenue.d.ts +327 -0
  23. package/src/lib/model/campaign-revenue.js +332 -0
  24. package/src/lib/model/campaign-revenue.js.map +1 -0
  25. package/src/lib/model/campaign-send-time.d.ts +74 -0
  26. package/src/lib/model/campaign-send-time.js +120 -0
  27. package/src/lib/model/campaign-send-time.js.map +1 -0
  28. package/src/lib/model/email-record.d.ts +175 -0
  29. package/src/lib/model/email-record.js +198 -0
  30. package/src/lib/model/email-record.js.map +1 -0
  31. package/src/lib/model/index.d.ts +53 -0
  32. package/src/lib/model/index.js +48 -0
  33. package/src/lib/model/index.js.map +1 -0
@@ -0,0 +1,120 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /*
18
+ * The four figure primitives now live in `@aglyn/shared-ui-jsx` so the forms
19
+ * report and the email reports share one implementation of "a rate names its
20
+ * denominator". Re-exported here because the email cards import them from
21
+ * this path, and a rate that renders differently on two surfaces is the thing
22
+ * the shared copy exists to prevent.
23
+ */ export { Figure, percent, RateRow, Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
24
+ import { Stack, Typography } from "@mui/material";
25
+ /*
26
+ * Money stays here. It is not a figure primitive: it carries a currency, and
27
+ * this surface deliberately refuses to sum two of them into one number.
28
+ */ export function money(cents, currency) {
29
+ const amount = cents / 100;
30
+ try {
31
+ return new Intl.NumberFormat(undefined, {
32
+ style: 'currency',
33
+ currency: currency.toUpperCase()
34
+ }).format(amount);
35
+ } catch (unused) {
36
+ return `${amount.toFixed(2)} ${currency.toUpperCase()}`;
37
+ }
38
+ }
39
+ /**
40
+ * One money figure, with the population it is averaged over named.
41
+ *
42
+ * The same rule {@link RateRow} enforces for a percentage, applied to an
43
+ * average — they go wrong the same way. "$0.42 per recipient" is meaningless
44
+ * without the recipient count, and worse than meaningless when the reader
45
+ * assumes a different one from the writer.
46
+ */ export function MoneyPerMessageRow(props) {
47
+ const { label, figure } = props;
48
+ return /*#__PURE__*/ _jsxs(Stack, {
49
+ direction: "row",
50
+ spacing: 2,
51
+ sx: {
52
+ justifyContent: 'space-between',
53
+ alignItems: 'baseline'
54
+ },
55
+ children: [
56
+ /*#__PURE__*/ _jsx(Typography, {
57
+ variant: "body2",
58
+ children: label
59
+ }),
60
+ figure ? /*#__PURE__*/ _jsxs(Stack, {
61
+ direction: "row",
62
+ spacing: 1,
63
+ sx: {
64
+ alignItems: 'baseline'
65
+ },
66
+ children: [
67
+ /*#__PURE__*/ _jsx(Typography, {
68
+ variant: "body2",
69
+ sx: {
70
+ fontWeight: 'bold'
71
+ },
72
+ children: money(figure.cents, figure.currency)
73
+ }),
74
+ /*#__PURE__*/ _jsx(Typography, {
75
+ variant: "caption",
76
+ color: "text.secondary",
77
+ children: `${money(figure.numeratorCents, figure.currency)} over ${figure.denominator.toLocaleString()} ${figure.denominatorLabel}`
78
+ })
79
+ ]
80
+ }) : /*#__PURE__*/ _jsx(Typography, {
81
+ variant: "caption",
82
+ color: "text.secondary",
83
+ children: '— not enough recorded to compute'
84
+ })
85
+ ]
86
+ });
87
+ }
88
+ MoneyPerMessageRow.displayName = 'MoneyPerMessageRow';
89
+ /**
90
+ * One money total, with what it counts named underneath.
91
+ *
92
+ * {@link Figure}'s contract in currency: `null` draws a dash and never a
93
+ * zero, because "no attribution has ever been recorded" and "this campaign
94
+ * earned nothing" lead a merchant to opposite conclusions about whether to
95
+ * send another one.
96
+ */ export function MoneyFigure(props) {
97
+ return /*#__PURE__*/ _jsxs(Stack, {
98
+ sx: {
99
+ minWidth: 140
100
+ },
101
+ children: [
102
+ /*#__PURE__*/ _jsx(Typography, {
103
+ variant: "h6",
104
+ children: props.cents === null ? '—' : money(props.cents, props.currency)
105
+ }),
106
+ /*#__PURE__*/ _jsx(Typography, {
107
+ variant: "body2",
108
+ children: props.label
109
+ }),
110
+ /*#__PURE__*/ _jsx(Typography, {
111
+ variant: "caption",
112
+ color: "text.secondary",
113
+ children: props.cents === null ? 'not recorded' : props.note
114
+ })
115
+ ]
116
+ });
117
+ }
118
+ MoneyFigure.displayName = 'MoneyFigure';
119
+
120
+ //# sourceMappingURL=report-figures.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/components/report-figures.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*\n * The four figure primitives now live in `@aglyn/shared-ui-jsx` so the forms\n * report and the email reports share one implementation of \"a rate names its\n * denominator\". Re-exported here because the email cards import them from\n * this path, and a rate that renders differently on two surfaces is the thing\n * the shared copy exists to prevent.\n */\nexport {\n Figure,\n percent,\n RateRow,\n Section,\n type MeasuredRate,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\n\nimport { Stack, Typography } from '@mui/material'\nimport type { CampaignMoneyPerMessage } from '../model/campaign-revenue'\nimport { percent as sharedPercent } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\n\n/*\n * Money stays here. It is not a figure primitive: it carries a currency, and\n * this surface deliberately refuses to sum two of them into one number.\n */\nexport function money(cents: number, currency: string): string {\n const amount = cents / 100\n try {\n return new Intl.NumberFormat(undefined, {\n style: 'currency',\n currency: currency.toUpperCase(),\n }).format(amount)\n } catch {\n return `${amount.toFixed(2)} ${currency.toUpperCase()}`\n }\n}\n\n/**\n * One money figure, with the population it is averaged over named.\n *\n * The same rule {@link RateRow} enforces for a percentage, applied to an\n * average — they go wrong the same way. \"$0.42 per recipient\" is meaningless\n * without the recipient count, and worse than meaningless when the reader\n * assumes a different one from the writer.\n */\nexport function MoneyPerMessageRow(props: {\n label: string\n figure: CampaignMoneyPerMessage | null\n}) {\n const { label, figure } = props\n return (\n <Stack\n direction=\"row\"\n spacing={2}\n sx={{ justifyContent: 'space-between', alignItems: 'baseline' }}\n >\n <Typography variant=\"body2\">{label}</Typography>\n {figure ? (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'baseline' }}>\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {money(figure.cents, figure.currency)}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${money(figure.numeratorCents, figure.currency)} over ${figure.denominator.toLocaleString()} ${figure.denominatorLabel}`}\n </Typography>\n </Stack>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'— not enough recorded to compute'}\n </Typography>\n )}\n </Stack>\n )\n}\nMoneyPerMessageRow.displayName = 'MoneyPerMessageRow'\n\n/**\n * One money total, with what it counts named underneath.\n *\n * {@link Figure}'s contract in currency: `null` draws a dash and never a\n * zero, because \"no attribution has ever been recorded\" and \"this campaign\n * earned nothing\" lead a merchant to opposite conclusions about whether to\n * send another one.\n */\nexport function MoneyFigure(props: {\n label: string\n cents: number | null\n currency: string\n note: string\n}) {\n return (\n <Stack sx={{ minWidth: 140 }}>\n <Typography variant=\"h6\">\n {props.cents === null ? '—' : money(props.cents, props.currency)}\n </Typography>\n <Typography variant=\"body2\">{props.label}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.cents === null ? 'not recorded' : props.note}\n </Typography>\n </Stack>\n )\n}\nMoneyFigure.displayName = 'MoneyFigure'\n"],"names":["Figure","percent","RateRow","Section","Stack","Typography","money","cents","currency","amount","Intl","NumberFormat","undefined","style","toUpperCase","format","toFixed","MoneyPerMessageRow","props","label","figure","direction","spacing","sx","justifyContent","alignItems","variant","fontWeight","color","numeratorCents","denominator","toLocaleString","denominatorLabel","displayName","MoneyFigure","minWidth","note"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;CAMC,GACD,SACEA,MAAM,EACNC,OAAO,EACPC,OAAO,EACPC,OAAO,QAEF,6DAA4D;AAEnE,SAASC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AAIjD;;;CAGC,GACD,OAAO,SAASC,MAAMC,KAAa,EAAEC,QAAgB;IACnD,MAAMC,SAASF,QAAQ;IACvB,IAAI;QACF,OAAO,IAAIG,KAAKC,YAAY,CAACC,WAAW;YACtCC,OAAO;YACPL,UAAUA,SAASM,WAAW;QAChC,GAAGC,MAAM,CAACN;IACZ,EAAE,eAAM;QACN,OAAO,GAAGA,OAAOO,OAAO,CAAC,GAAG,CAAC,EAAER,SAASM,WAAW,IAAI;IACzD;AACF;AAEA;;;;;;;CAOC,GACD,OAAO,SAASG,mBAAmBC,KAGlC;IACC,MAAM,EAAEC,KAAK,EAAEC,MAAM,EAAE,GAAGF;IAC1B,qBACE,MAACd;QACCiB,WAAU;QACVC,SAAS;QACTC,IAAI;YAAEC,gBAAgB;YAAiBC,YAAY;QAAW;;0BAE9D,KAACpB;gBAAWqB,SAAQ;0BAASP;;YAC5BC,uBACC,MAAChB;gBAAMiB,WAAU;gBAAMC,SAAS;gBAAGC,IAAI;oBAAEE,YAAY;gBAAW;;kCAC9D,KAACpB;wBAAWqB,SAAQ;wBAAQH,IAAI;4BAAEI,YAAY;wBAAO;kCAClDrB,MAAMc,OAAOb,KAAK,EAAEa,OAAOZ,QAAQ;;kCAEtC,KAACH;wBAAWqB,SAAQ;wBAAUE,OAAM;kCACjC,GAAGtB,MAAMc,OAAOS,cAAc,EAAET,OAAOZ,QAAQ,EAAE,MAAM,EAAEY,OAAOU,WAAW,CAACC,cAAc,GAAG,CAAC,EAAEX,OAAOY,gBAAgB,EAAE;;;+BAI9H,KAAC3B;gBAAWqB,SAAQ;gBAAUE,OAAM;0BACjC;;;;AAKX;AACAX,mBAAmBgB,WAAW,GAAG;AAEjC;;;;;;;CAOC,GACD,OAAO,SAASC,YAAYhB,KAK3B;IACC,qBACE,MAACd;QAAMmB,IAAI;YAAEY,UAAU;QAAI;;0BACzB,KAAC9B;gBAAWqB,SAAQ;0BACjBR,MAAMX,KAAK,KAAK,OAAO,MAAMD,MAAMY,MAAMX,KAAK,EAAEW,MAAMV,QAAQ;;0BAEjE,KAACH;gBAAWqB,SAAQ;0BAASR,MAAMC,KAAK;;0BACxC,KAACd;gBAAWqB,SAAQ;gBAAUE,OAAM;0BACjCV,MAAMX,KAAK,KAAK,OAAO,iBAAiBW,MAAMkB,IAAI;;;;AAI3D;AACAF,YAAYD,WAAW,GAAG"}
@@ -0,0 +1,359 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * A CAMPAIGN IS A CONTAINER; A SEND IS ONE MESSAGE INSIDE IT.
19
+ *
20
+ * ## The two collections, and why there are two
21
+ *
22
+ * `hosts/{hostId}/campaigns/{sendId}` holds a SEND: one subject, one body,
23
+ * one audience, one set of counters. That is what the collection has always
24
+ * held, and it is why the container could not simply be that document grown
25
+ * new fields.
26
+ *
27
+ * **Its ids are load-bearing outside this repo.** Every unsubscribe link that
28
+ * has ever gone out carries `cid={sendId}`, those emails sit in inboxes
29
+ * forever, and the `cid` is inside the link's HMAC — so a send id that stops
30
+ * resolving is an opt-out that stops working, which is a compliance failure
31
+ * rather than a broken page. `/marketing/campaigns/{sendId}` is likewise
32
+ * linkable by design: a merchant pastes it into a message about last week's
33
+ * send.
34
+ *
35
+ * So the send collection is left exactly where it is, under exactly its
36
+ * existing ids, and the container is a new collection above it:
37
+ * `hosts/{hostId}/emailCampaigns/{campaignId}`. A send joins one by carrying
38
+ * {@link CAMPAIGN_SEND_CONTAINER_FIELD}; a send written before containers
39
+ * existed carries nothing, and {@link campaignListRows} presents it as a
40
+ * container of one rather than hiding it.
41
+ *
42
+ * That last property is what makes this migration-free. There is no backfill
43
+ * to run, no window in which a merchant's history is missing, and no id
44
+ * rewritten anywhere.
45
+ *
46
+ * ## Why the arithmetic is here
47
+ *
48
+ * The same reason `campaign-report.ts` gives for the per-send rates: a
49
+ * denominator chosen in JSX is a denominator nobody tests. Aggregating across
50
+ * sends adds one problem the single-send report does not have — some sends
51
+ * recorded a field and others never did — and summing those into one number
52
+ * silently reports a partial total as a complete one. Every aggregate here
53
+ * therefore reports how many sends it could measure.
54
+ */
55
+ import { type CampaignRate, type CampaignStats } from './campaign-report';
56
+ /**
57
+ * The field on a SEND naming the campaign it belongs to.
58
+ *
59
+ * Not `campaignId`: on a send document that name already means the send's own
60
+ * id — it is what `cid` carries and what the report route addresses — and one
61
+ * word meaning both would be read into the other on the first edit.
62
+ */
63
+ export declare const CAMPAIGN_SEND_CONTAINER_FIELD = "emailCampaignId";
64
+ /**
65
+ * A campaign: the container, not a message.
66
+ *
67
+ * Stored at `hosts/{hostId}/emailCampaigns/{campaignId}`.
68
+ */
69
+ export interface EmailCampaign {
70
+ $id: string;
71
+ /** What the merchant called it. */
72
+ name: string;
73
+ /** When the campaign window opens. Null for a campaign with no dates. */
74
+ startAtMs?: number | null;
75
+ /** When it closes. Null for an open-ended campaign. */
76
+ endAtMs?: number | null;
77
+ /** Org email lists this campaign is aimed at, by list id. */
78
+ listIds?: string[];
79
+ /**
80
+ * The stream this campaign's emails open on.
81
+ *
82
+ * A DEFAULT, not a constraint. The topic decides who a send skips, what the
83
+ * preference page linked from the footer highlights, and which stream a
84
+ * resulting opt-out is recorded against — all facts about one MESSAGE, and
85
+ * one campaign may legitimately carry a newsletter and a promotion. So the
86
+ * composer's picker is what the send records; this is what the picker opens
87
+ * on, which is what stops a "Sales" campaign quietly mailing under
88
+ * `marketing`.
89
+ */
90
+ topicId?: string;
91
+ createdAtMs?: number;
92
+ createdBy?: string;
93
+ deletedAt?: unknown;
94
+ }
95
+ /** One send, as much of it as a list or a rollup needs. */
96
+ export interface CampaignSend {
97
+ $id: string;
98
+ subject?: string;
99
+ /** The audience KIND: `'leads'`, `'members'`, `'segment'`, `'list'`. */
100
+ audience?: string;
101
+ /** The list this send addressed, when the kind is `'list'`. */
102
+ listId?: string;
103
+ /** The segment this send addressed, when the kind is `'segment'`. */
104
+ segmentId?: string;
105
+ /** Which container it belongs to, absent on a send written before them. */
106
+ emailCampaignId?: string;
107
+ status?: string;
108
+ sentAt?: {
109
+ seconds?: number;
110
+ } | null;
111
+ sendAtMs?: number;
112
+ /**
113
+ * When the record was minted, stamped by every writer that creates one.
114
+ *
115
+ * Absent on a send written before the stamp existed. The lists that draw
116
+ * drafts beside sends order on it through `emailListTimeMs`, which is why
117
+ * it is here rather than only on the loose record shape: a draft carries
118
+ * neither `sentAt` nor `sendAtMs`, so this is the only time it has.
119
+ */
120
+ createdAtMs?: number;
121
+ stats?: CampaignStats;
122
+ /** How far a send that goes out over several batches has got. */
123
+ resume?: CampaignResume;
124
+ }
125
+ /**
126
+ * The batch state the sender writes on an email that is still going out.
127
+ *
128
+ * Absent on every send that finished in one batch, and on every send that
129
+ * predates batching — which is why {@link campaignSendProgress} treats a
130
+ * missing record as "this is not a batched send" rather than as zero.
131
+ */
132
+ export interface CampaignResume {
133
+ /** People the email has resolved and not yet addressed. */
134
+ remaining?: number;
135
+ /** Batches it has run. */
136
+ batch?: number;
137
+ /** When the next batch may go, ms. Zero when there is not going to be one. */
138
+ nextAtMs?: number;
139
+ /** Why it stopped short, when it did. */
140
+ stop?: string;
141
+ }
142
+ /**
143
+ * WHAT A SEND IS ACTUALLY DOING, for a row that would otherwise lie.
144
+ *
145
+ * An email larger than one send may carry is delivered over several batches,
146
+ * and between them it is stored as `scheduled` — the state the processor
147
+ * claims, and the only one that resumes it without a second index and a
148
+ * second query. Read literally, that is a row saying "not sent yet" about an
149
+ * email that has already put five hundred messages in five hundred inboxes.
150
+ *
151
+ * So the stored fields are not the sentence. This is: it takes the status,
152
+ * the delivered count and the batch record, and answers what a person needs
153
+ * to see. Derived at read time and never persisted, exactly as
154
+ * `campaignWindowState` beside it is, because it is a description of stored
155
+ * facts and not a fact of its own.
156
+ *
157
+ * ## The four states, and which stored shape each one is
158
+ *
159
+ * - `pending` — `scheduled`, nothing delivered. A campaign waiting for its
160
+ * time. This is what `scheduled` meant before batching and still does.
161
+ * - `sending` — `scheduled` or `sending` with something delivered and more to
162
+ * come. The state this function exists for.
163
+ * - `sent` — finished, whether in one batch or six.
164
+ * - `stopped` — finished with people it never addressed: canceled mid-flight,
165
+ * failed mid-flight, or stopped by the batch guard. The count is what makes
166
+ * this legible rather than alarming — an email that reached 2,400 of 3,000
167
+ * and stopped is a different conversation from one that reached nobody.
168
+ */
169
+ export type CampaignSendProgressState = 'pending' | 'sending' | 'sent' | 'stopped';
170
+ export interface CampaignSendProgress {
171
+ state: CampaignSendProgressState;
172
+ /** Messages this email has delivered. */
173
+ reached: number;
174
+ /**
175
+ * The audience it is working through, when one was recorded. Null when the
176
+ * send never recorded an audience size, which is every send that predates
177
+ * the figure — reported as null rather than as `reached` so a surface does
178
+ * not present a floor as a total.
179
+ */
180
+ audience: number | null;
181
+ /** People it has resolved and not yet addressed. */
182
+ remaining: number;
183
+ /** Batches it has run. Zero for a send that never batched. */
184
+ batch: number;
185
+ /** When the next batch may go, ms. Zero unless {@link state} is `sending`. */
186
+ nextAtMs: number;
187
+ /** One line a surface may show verbatim. */
188
+ label: string;
189
+ }
190
+ export declare function campaignSendProgress(send: CampaignSend | null | undefined): CampaignSendProgress;
191
+ /**
192
+ * What a ROW says about one email, in one word and one line.
193
+ *
194
+ * {@link campaignSendProgress} answers what a send is DOING, and a draft is
195
+ * not doing anything: it has no status the progress states cover, and — since
196
+ * an absent status reads as `sent` and a draft has no counters — asking it
197
+ * about one answers "Sent to 0", which is the worst available sentence about
198
+ * an email nobody has written yet. So the draft is settled here and
199
+ * everything else is deferred, unchanged, to the derivation that owns it.
200
+ *
201
+ * One helper rather than the same two-line branch on four surfaces. The
202
+ * campaigns table, the emails list, an email's own page and a campaign's
203
+ * emails table all draw this, and four copies is how three of them come to
204
+ * say "Scheduled" about a campaign that has delivered five hundred messages.
205
+ */
206
+ export type CampaignSendDisplayState = 'draft' | CampaignSendProgressState;
207
+ export interface CampaignSendDisplay {
208
+ state: CampaignSendDisplayState;
209
+ /** One line a surface may show verbatim. */
210
+ label: string;
211
+ /** The progress underneath, for a surface that wants the figures. */
212
+ progress: CampaignSendProgress;
213
+ }
214
+ export declare function campaignSendDisplay(send: CampaignSend | null | undefined): CampaignSendDisplay;
215
+ /**
216
+ * Whether this email is between batches, with more of its audience to reach.
217
+ *
218
+ * The one question two controls on an email's page turn on. It is stored as
219
+ * `scheduled` — the state the processor claims — so a surface reading the
220
+ * status alone offers "Send now" on a campaign that is already going out, and
221
+ * `sendNow` re-resolves the WHOLE audience rather than continuing: everyone
222
+ * already reached would receive a second copy under the same `cid`.
223
+ */
224
+ export declare function campaignSendIsMidFlight(send: CampaignSend | null | undefined): boolean;
225
+ /**
226
+ * The lists ONE SEND addressed — which can be narrower than the lists its
227
+ * campaign is aimed at.
228
+ *
229
+ * A campaign holds the lists a merchant plans to reach; each send inside it
230
+ * picks one audience, and that audience may be a segment or the site's leads
231
+ * rather than any of them. Answering from the send is therefore the only
232
+ * honest answer for a send's own detail page.
233
+ *
234
+ * An array for a document that stores one id, deliberately: the question
235
+ * "which lists did this reach" has a plural answer everywhere it is asked,
236
+ * and a caller that unwraps a single id today is a caller to revisit if a
237
+ * send ever addresses two.
238
+ */
239
+ export declare function campaignSendListIds(send: CampaignSend): string[];
240
+ /**
241
+ * A number summed across sends, with how much of the campaign it covers.
242
+ *
243
+ * `value` is `null` when NO send recorded the field — which is not zero, for
244
+ * the reason `campaign-report.ts` gives at length: an unrecorded delivery
245
+ * count and a delivery count of zero lead a merchant to opposite conclusions
246
+ * about their sending domain.
247
+ *
248
+ * `recorded` below `sends` means the total is a floor. A campaign whose
249
+ * older sends predate the delivery webhook has a real number that describes
250
+ * part of itself, and saying which part is the difference between a total and
251
+ * a guess.
252
+ */
253
+ export interface CampaignAggregate {
254
+ value: number | null;
255
+ /** Sends that recorded this field. */
256
+ recorded: number;
257
+ /** Sends in the campaign. */
258
+ sends: number;
259
+ }
260
+ /** Every rolled-up figure for one campaign. */
261
+ export interface CampaignRollup {
262
+ /** Sends that have actually gone out. */
263
+ sends: number;
264
+ /** Sends still waiting for their send time, having delivered nothing. */
265
+ scheduled: number;
266
+ /**
267
+ * Sends part way through an audience larger than one batch.
268
+ *
269
+ * Counted apart from `scheduled` and from `sends`, because it is neither:
270
+ * mail has gone out, and more is going to. Both of those are facts a
271
+ * merchant reading a campaign row needs, and the stored status carries
272
+ * only the first.
273
+ */
274
+ sending: number;
275
+ /**
276
+ * Emails that have been created and not yet written or sent.
277
+ *
278
+ * Counted apart from `sends` and from `scheduled`, because a draft is
279
+ * neither: it has mailed nobody, and it is not on the clock to.
280
+ */
281
+ drafts: number;
282
+ addressed: CampaignAggregate;
283
+ sent: CampaignAggregate;
284
+ delivered: CampaignAggregate;
285
+ opens: CampaignAggregate;
286
+ uniqueOpens: CampaignAggregate;
287
+ clicks: CampaignAggregate;
288
+ uniqueClicks: CampaignAggregate;
289
+ bounced: CampaignAggregate;
290
+ complained: CampaignAggregate;
291
+ unsubscribes: CampaignAggregate;
292
+ /** Distinct openers over delivered, across every send that recorded both. */
293
+ openRate: CampaignRate | null;
294
+ /** Distinct clickers over delivered. */
295
+ clickRate: CampaignRate | null;
296
+ /** Unsubscribes over delivered. */
297
+ unsubscribeRate: CampaignRate | null;
298
+ /** The most recent send time in the campaign, for ordering a list. */
299
+ lastSentAtMs: number | null;
300
+ }
301
+ /** When a send happened, in epoch milliseconds, or null. */
302
+ export declare function campaignSendAtMs(send: CampaignSend): number | null;
303
+ /**
304
+ * Rolls a campaign's sends into one set of figures.
305
+ *
306
+ * Rates are taken over the sends that recorded BOTH sides, so a campaign
307
+ * whose first send predates the delivery webhook reports the open rate of the
308
+ * sends that can be measured rather than one deflated by a send with no
309
+ * denominator.
310
+ */
311
+ export declare function campaignRollup(sends: CampaignSend[]): CampaignRollup;
312
+ /**
313
+ * Where a campaign stands against its own window.
314
+ *
315
+ * Derived at read time and never persisted — the `status` values on a SEND
316
+ * (`sent`, `scheduled`, `canceled`, `failed`) are stored strings that a
317
+ * processor branches on, and a display state sharing those spellings would
318
+ * eventually be written back.
319
+ */
320
+ export type CampaignWindowState = 'undated' | 'upcoming' | 'running' | 'ended';
321
+ export declare function campaignWindowState(campaign: Pick<EmailCampaign, 'startAtMs' | 'endAtMs'>, nowMs: number): CampaignWindowState;
322
+ /** One row of the campaigns table. */
323
+ export interface CampaignListRow {
324
+ /** The id the detail route resolves — a container id, or a send id. */
325
+ id: string;
326
+ name: string;
327
+ /**
328
+ * True when this row IS a send with no container.
329
+ *
330
+ * A campaign sent before containers existed is shown as a campaign of one
331
+ * rather than dropped from the list, and the detail route falls back to the
332
+ * send's own report for it. Nothing about that send is rewritten.
333
+ */
334
+ legacy: boolean;
335
+ startAtMs: number | null;
336
+ endAtMs: number | null;
337
+ listIds: string[];
338
+ sends: CampaignSend[];
339
+ rollup: CampaignRollup;
340
+ windowState: CampaignWindowState;
341
+ /** For ordering: the campaign's start, else its most recent send. */
342
+ atMs: number | null;
343
+ }
344
+ /**
345
+ * The campaigns table's rows: every container, plus every send that belongs
346
+ * to none.
347
+ *
348
+ * The second half is what makes the container additive. A merchant's history
349
+ * is the sends they already have; a list that showed only containers would
350
+ * read as an empty product on the day this shipped, and a backfill that
351
+ * adopted each old send into a container of one would rewrite documents whose
352
+ * ids are cited by mail already delivered. Adopting them AT READ TIME costs a
353
+ * pass over a list already in memory and rewrites nothing.
354
+ *
355
+ * Newest first, on the start date where there is one and the last send
356
+ * otherwise. Rows with no date at all sort last: they are campaigns nobody
357
+ * has scheduled or sent, and dating them from nothing would be an invention.
358
+ */
359
+ export declare function campaignListRows(campaigns: EmailCampaign[], sends: CampaignSend[], nowMs: number): CampaignListRow[];