@aglyn/shared-ui-email-campaigns 1.0.0-beta.217 → 1.0.0-beta.219
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 +3 -3
- package/src/lib/model/campaign-report.js +1 -1
- package/src/lib/model/campaign-report.js.map +1 -1
- package/src/lib/model/index.d.ts +0 -1
- package/src/lib/model/index.js +0 -1
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/model/campaign-send-time.d.ts +0 -74
- package/src/lib/model/campaign-send-time.js +0 -120
- package/src/lib/model/campaign-send-time.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/shared-ui-email-campaigns",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.219",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"./package.json": "./package.json"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
32
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.219",
|
|
33
|
+
"@aglyn/shared-util-email": "1.0.0-beta.219",
|
|
34
34
|
"@swc/helpers": "0.5.23"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
@@ -238,7 +238,7 @@
|
|
|
238
238
|
* LINK-LEVEL CLICKS.
|
|
239
239
|
*
|
|
240
240
|
* Resend's `email.clicked` payload carries `data.click.link`, the destination
|
|
241
|
-
* the recipient followed; `
|
|
241
|
+
* the recipient followed; the mail provider's `deliveryEvents` already reads it
|
|
242
242
|
* into `EmailDeliveryEvent.link`, and the per-recipient delivery log already
|
|
243
243
|
* stores it. What did not exist was an aggregate — which is what "link
|
|
244
244
|
* clicks" means, and it cannot be produced from the delivery log without
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/campaign-report.ts"],"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 * CAMPAIGN REPORTING MATH — the only place a rate is computed.\n *\n * ## Why a pure module and not a component\n *\n * Every number on the report screen is a division, and a division is where\n * email reporting goes wrong. Putting the arithmetic in JSX means the\n * denominator is chosen by whoever writes the next card, in a file nobody\n * tests for arithmetic; putting it here means each rate is named once,\n * carries its own denominator as data, and is provable.\n *\n * ## The rule this module exists to enforce\n *\n * **A rate is a triple — numerator, denominator, and the NAME of the\n * denominator — or it is not reported.** An open rate over `sent` and an open\n * rate over `delivered` are different numbers with the same label, and the\n * gap between them is exactly the mail that bounced. The industry convention\n * is over `delivered`, and a report that quietly used `sent` would read\n * higher than the same campaign measured anywhere else.\n *\n * So {@link CampaignRate} carries `denominatorLabel`, and the screen is\n * required to render it. There is no overload that omits it.\n *\n * ## Why some rates are deliberately absent\n *\n * {@link campaignRate} answers `null`, not zero, when it cannot divide:\n *\n * - **A zero denominator.** 0 opens out of 0 delivered is not a 0% open\n * rate, it is no open rate. Rendering 0% invites the reader to compare it\n * with a campaign that really did fail.\n * - **An UNKNOWN denominator.** `delivered` is counted by the delivery\n * webhook, which was connected after some campaigns were sent. A campaign\n * with 400 sends and no delivery events has an unknown denominator, not a\n * denominator of zero — and dividing by `sent` instead is precisely the\n * flattering substitution above.\n *\n * ## The structural-zero window\n *\n * Click tracking rewrites links in the HTML part. Sends that carried no HTML\n * part were therefore untrackable, and every one of those campaigns reports 0\n * clicks whatever the recipients actually did — a real 0 and a structural 0\n * rendered identically. `send-email.ts` now synthesises an HTML part for a\n * text-only send, so every send after that carries one, and\n * `campaign-send.ts` records {@link CampaignStats.clickTracked} to say so.\n *\n * A campaign with no such marker predates the record. Its click COUNT is\n * still shown — it is a real count of real events — but no click RATE is\n * computed from it, because a rate presents the number as a measurement of\n * the audience and for those campaigns it is a measurement of the sender.\n */\n\n/**\n * The `stats` map on `hosts/{hostId}/campaigns/{campaignId}`.\n *\n * Every field is optional and every reader defaults it, because these are\n * written by three different writers at three different times — the send, the\n * delivery webhook, the unsubscribe handler — and a campaign is a legitimate,\n * readable document from the instant the first of them lands.\n */\nexport interface CampaignStats {\n /*========================================\n * WRITTEN BY THE SEND. Never recomputed.\n *\n * These are the truth of what happened, recorded once by the code that did\n * it. Re-deriving any of them at read time would produce a number that\n * disagrees with the send — the audience has moved on since, suppressions\n * have been added, consent has been recorded — and the recorded one is the\n * one that describes the campaign.\n *=======================================*/\n /** The whole audience the send was taken from, before the per-send cap. */\n audienceSize?: number\n /** `audienceSize` is a FLOOR: audience resolution hit its read ceiling. */\n audienceSizeTruncated?: boolean\n /** Addresses this send ADDRESSED — the audience after the per-send cap. */\n recipients?: number\n /** Messages the provider accepted. The `sent` in \"sent/recipients\". */\n sent?: number\n /** Of the audience, how many carry a recorded marketing consent basis. */\n consented?: number\n /** Of `consented`, how many hold a basis an operator asserted for them. */\n consentedByOperator?: number\n /** Of the audience, how many are reachable only because enforcement is\n * not retroactive — the population a strict consent policy removes. */\n grandfathered?: number\n /** Of the audience, how many the consent rule refused to mail. */\n consentWithheld?: number\n /** Of `recipients`, how many were already suppressed (unsubscribed,\n * bounced or complained on an earlier send). */\n suppressed?: number\n /** Of `recipients`, how many asked this site for mail less often than this\n * send would have arrived. Still subscribed; reached by a later campaign. */\n cadenceHeld?: number\n /**\n * Of `recipients`, how many the deliverability check took out (AGL-3328):\n * their domain has no mail server, or their mail gateway refused this\n * sending domain twice in thirty days and delivered nothing.\n */\n noMailServer?: number\n gatewayHeld?: number\n /** Recipients the hourly send governor refused mid-batch. */\n deferred?: number\n /**\n * This send carried an HTML part, so its links were trackable.\n *\n * Absent on every campaign sent before the field existed — see the\n * structural-zero note in this module's header. Absent is NOT false; it is\n * \"not recorded\", and the report says so rather than guessing.\n */\n clickTracked?: boolean\n /** Per-variant send counts for an A/B campaign. */\n variantSends?: Record<string, number>\n\n /*========================================\n * WRITTEN BY THE DELIVERY WEBHOOK, as increments.\n *=======================================*/\n /** Messages the receiving server accepted. The rate DENOMINATOR. */\n delivered?: number\n /** Open EVENTS. One reader opening four times counts four. */\n opens?: number\n /** Click EVENTS. One reader clicking three links counts three. */\n clicks?: number\n /** Messages whose FIRST open was seen — distinct readers who opened. */\n uniqueOpens?: number\n /** Messages whose FIRST click was seen — distinct readers who clicked. */\n uniqueClicks?: number\n /** Messages that bounced, permanent and transient together. */\n bounced?: number\n /** Recipients who pressed \"report spam\". */\n complained?: number\n\n /*========================================\n * WRITTEN BY THE UNSUBSCRIBE HANDLER.\n *=======================================*/\n /** Recipients who unsubscribed through THIS campaign's link. */\n unsubscribes?: number\n}\n\n/**\n * One rate, with the denominator it was taken over named as data.\n *\n * `denominatorLabel` is not a display nicety. It is the field that makes two\n * numbers called \"open rate\" distinguishable, and the screen renders it\n * beside the percentage for that reason.\n */\nexport interface CampaignRate {\n /** 0–1. Multiply for display; the model never formats. */\n value: number\n numerator: number\n denominator: number\n /** Reader-facing name of the denominator, e.g. `'delivered'`. */\n denominatorLabel: string\n}\n\n/**\n * A rate, or `null` when one cannot honestly be taken.\n *\n * `null` on a zero or unknown denominator — see the module header for why\n * that is not the same as 0%.\n */\nexport function campaignRate(\n numerator: number | undefined,\n denominator: number | undefined,\n denominatorLabel: string,\n): CampaignRate | null {\n const top = Number(numerator ?? 0)\n const bottom = Number(denominator ?? 0)\n if (!Number.isFinite(top) || !Number.isFinite(bottom)) return null\n if (bottom <= 0) return null\n return {\n value: top / bottom,\n numerator: top,\n denominator: bottom,\n denominatorLabel,\n }\n}\n\n/** Why a number the report would otherwise show is being withheld. */\nexport interface CampaignCaveat {\n /** Stable id, so a spec can assert on the caveat rather than its prose. */\n id:\n | 'delivery-unrecorded'\n | 'click-tracking-unrecorded'\n | 'audience-truncated'\n | 'send-deferred'\n /* Raised by `campaign-revenue.ts`, which reports through this shape so\n * the screen has one way of saying \"a number is being withheld and here\n * is why\" rather than one per section. */\n | 'revenue-denominator-unrecorded'\n | 'revenue-multi-currency'\n | 'revenue-mid-flight'\n | 'revenue-mixed-model'\n /* Raised by `campaign-conversions.ts`, through this shape for the same\n * reason: one way of saying \"a number is being withheld, or must not be\n * read the obvious way, and here is why\". The first two are the reasons\n * the four conversion kinds stand apart instead of totalling; the last\n * two qualify the uncredited figure rather than withholding it. */\n | 'conversions-kinds-overlap'\n | 'conversions-web-not-rolled-up'\n | 'conversions-unattributed-is-a-ceiling'\n | 'conversions-total-crosses-hosts'\n /* Raised by the Sequences block below (AGL-3254): the five figures are\n * a funnel read across, never a sum. */\n | 'sequences-funnel-not-summed'\n message: string\n}\n\n/** One population the send measured, for the audience breakdown. */\nexport interface CampaignPopulation {\n id: string\n label: string\n count: number\n /** What this count is a part OF, named. */\n ofLabel: string\n of: number\n}\n\n/** Everything the report screen renders, decided here rather than in JSX. */\nexport interface CampaignReport {\n sent: number\n recipients: number\n delivered: number | null\n opens: number\n clicks: number\n uniqueOpens: number | null\n uniqueClicks: number | null\n bounced: number\n complained: number\n unsubscribes: number\n /** Rates, each `null` when its denominator is zero or unrecorded. */\n rates: {\n /** Accepted by the receiving server, over what the provider accepted. */\n delivery: CampaignRate | null\n /** Distinct readers who opened, over delivered. */\n open: CampaignRate | null\n /** Distinct readers who clicked, over delivered. */\n click: CampaignRate | null\n /**\n * Distinct clickers over distinct OPENERS — a different question from\n * `click`, and the one the two get confused for. It answers \"of the\n * people who read it, how many acted\", not \"of the people who received\n * it\". Reported separately and labelled separately, never as \"click\n * rate\".\n */\n clickToOpen: CampaignRate | null\n /** Bounced over what the provider accepted. */\n bounce: CampaignRate | null\n /** Complaints over delivered — the number mailbox providers judge on. */\n complaint: CampaignRate | null\n /** Unsubscribes through this campaign's link, over delivered. */\n unsubscribe: CampaignRate | null\n }\n populations: CampaignPopulation[]\n caveats: CampaignCaveat[]\n}\n\n/**\n * Turns a stored `stats` map into the report.\n *\n * ## The denominator decisions, in one place\n *\n * - **`delivered`** carries the engagement rates — open, click, complaint,\n * unsubscribe. Mail that bounced was never in front of a human, so\n * including it in the denominator of an open rate depresses a number that\n * describes the audience with a fact about the address list. This is also\n * the convention every other tool reports, which matters: a merchant\n * comparing our figure with their previous ESP's must be comparing the same\n * quantity.\n * - **`sent`** carries the delivery and bounce rates, because those describe\n * what happened to what we handed the provider, and `delivered` is the\n * numerator of one of them — a rate cannot be over itself.\n * - **`uniqueOpens`** is the open-rate numerator, not `opens`. Open EVENTS\n * over recipients can exceed 100% the moment one person opens twice, and a\n * percentage above 100 is how a reader learns the number means something\n * other than what it says. Both are shown; only the distinct count is\n * divided.\n *\n * ## `delivered` unknown vs. zero\n *\n * A campaign predating the delivery webhook records no `delivered` at all.\n * That is reported as `null` and every rate over it is withheld, with a\n * caveat naming the reason — rather than substituting `sent`, which would\n * silently publish the flattered number this module exists to refuse.\n */\nexport function campaignReport(stats: CampaignStats | undefined): CampaignReport {\n const source = stats ?? {}\n const sent = Number(source.sent ?? 0)\n const recipients = Number(source.recipients ?? 0)\n const opens = Number(source.opens ?? 0)\n const clicks = Number(source.clicks ?? 0)\n const bounced = Number(source.bounced ?? 0)\n const complained = Number(source.complained ?? 0)\n const unsubscribes = Number(source.unsubscribes ?? 0)\n\n /*\n * ABSENT, not zero. `stats.delivered` is written only by the delivery\n * webhook, so `undefined` means \"no delivery event has ever been recorded\n * for this campaign\" — which for an old campaign means the webhook was not\n * connected, and for a campaign sent thirty seconds ago means the events\n * are still in flight. Neither is \"nothing was delivered\", and both are\n * ruined by `?? 0`, which would turn the unknown into a hard zero and make\n * every rate over it `null` for the RIGHT answer by the WRONG reasoning —\n * and would render \"0 delivered\" on screen beside \"500 sent\".\n */\n const delivered =\n source.delivered === undefined ? null : Number(source.delivered)\n const uniqueOpens =\n source.uniqueOpens === undefined ? null : Number(source.uniqueOpens)\n const uniqueClicks =\n source.uniqueClicks === undefined ? null : Number(source.uniqueClicks)\n\n /*\n * The click rate is withheld for a campaign that never recorded carrying an\n * HTML part, even when clicks are non-zero and `delivered` is known. See\n * the structural-zero note in the module header: for those sends 0 is the\n * only value the number could ever have taken, so a rate computed from it\n * measures our sending code rather than the recipients.\n */\n const clickTrackable = source.clickTracked === true\n\n const rates: CampaignReport['rates'] = {\n /*\n * `null` when `delivered` is UNRECORDED, and this is the one rate where\n * the numerator can be unknown rather than zero.\n *\n * Everywhere else an absent numerator is a genuine nought — a campaign\n * with delivery events and no opens really does have a 0% open rate, and\n * that is worth showing. Here the numerator IS the unrecorded quantity,\n * so `campaignRate(undefined, 1000, 'sent')` would divide a missing\n * measurement by a real one and publish \"0.0% delivery rate — 0 of 1,000\n * sent\" for a campaign whose delivery events were merely never recorded.\n * That is the flattering-substitution failure this module exists to\n * refuse, running in the other direction: not a rate that reads too high,\n * but a campaign that reads as a total delivery failure.\n */\n delivery:\n delivered === null ? null : campaignRate(delivered, sent, 'sent'),\n open: campaignRate(uniqueOpens ?? undefined, delivered ?? undefined, 'delivered'),\n click: clickTrackable\n ? campaignRate(uniqueClicks ?? undefined, delivered ?? undefined, 'delivered')\n : null,\n clickToOpen: clickTrackable\n ? campaignRate(uniqueClicks ?? undefined, uniqueOpens ?? undefined, 'unique openers')\n : null,\n bounce: campaignRate(bounced, sent, 'sent'),\n complaint: campaignRate(complained, delivered ?? undefined, 'delivered'),\n unsubscribe: campaignRate(unsubscribes, delivered ?? undefined, 'delivered'),\n }\n\n /*\n * The populations the SEND measured, reported as parts of a named whole\n * rather than as bare counts. \"412 withheld\" invites the question \"out of\n * what\"; the answer is the audience, and it is a different whole from the\n * one `suppressed` is measured against — consent is decided over the whole\n * audience and suppression over the capped recipient list, because that is\n * where each check runs. Netting them into one column would present two\n * different denominators as one.\n */\n const audienceSize = Number(source.audienceSize ?? 0)\n const populations: CampaignPopulation[] = []\n const addPopulation = (\n id: string,\n label: string,\n count: number | undefined,\n ofLabel: string,\n of: number,\n ) => {\n if (count === undefined) return\n populations.push({ id, label, count: Number(count), ofLabel, of })\n }\n addPopulation(\n 'consented',\n 'Had a consent basis',\n source.consented,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'consentedByOperator',\n 'Consent asserted by an operator',\n source.consentedByOperator,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'grandfathered',\n 'Reachable only because consent is not enforced retroactively',\n source.grandfathered,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'consentWithheld',\n 'Withheld by the consent rule',\n source.consentWithheld,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'suppressed',\n 'Already suppressed',\n source.suppressed,\n 'addressed',\n recipients,\n )\n addPopulation(\n 'cadenceHeld',\n 'Asked for mail less often than this',\n source.cadenceHeld,\n 'addressed',\n recipients,\n )\n addPopulation(\n 'noMailServer',\n 'Excluded: no mail server',\n source.noMailServer,\n 'addressed',\n recipients,\n )\n addPopulation(\n 'gatewayHeld',\n 'Excluded: behind a gateway that refused this sender',\n source.gatewayHeld,\n 'addressed',\n recipients,\n )\n\n const caveats: CampaignCaveat[] = []\n if (delivered === null) {\n caveats.push({\n id: 'delivery-unrecorded',\n message:\n 'No delivery events have been recorded for this campaign, so open, ' +\n 'click, complaint and unsubscribe rates cannot be computed — every ' +\n 'one of them is taken over delivered. Counts below are still real.',\n })\n }\n if (!clickTrackable) {\n caveats.push({\n id: 'click-tracking-unrecorded',\n message:\n 'This send did not record carrying an HTML part. Click tracking ' +\n 'rewrites links in the HTML, so a send without one reports zero ' +\n 'clicks whatever recipients did. The click count is shown; no click ' +\n 'rate is computed from it.',\n })\n }\n if (source.audienceSizeTruncated) {\n caveats.push({\n id: 'audience-truncated',\n message:\n 'Audience resolution stopped at its read ceiling, so the audience ' +\n 'size is a floor — the real audience is at least this large, and ' +\n 'every share taken over it is at most the figure shown.',\n })\n }\n if (Number(source.deferred ?? 0) > 0) {\n caveats.push({\n id: 'send-deferred',\n message:\n `${Number(source.deferred)} recipients were held back by the hourly ` +\n 'send governor and never received this campaign. They are counted in ' +\n 'addressed, not in sent.',\n })\n }\n\n return {\n sent,\n recipients,\n delivered,\n opens,\n clicks,\n uniqueOpens,\n uniqueClicks,\n bounced,\n complained,\n unsubscribes,\n rates,\n populations,\n caveats,\n }\n}\n\n/*==========================================\n * LINK-LEVEL CLICKS.\n *\n * Resend's `email.clicked` payload carries `data.click.link`, the destination\n * the recipient followed; `normalizeResendDeliveryEvents` already reads it\n * into `EmailDeliveryEvent.link`, and the per-recipient delivery log already\n * stores it. What did not exist was an aggregate — which is what \"link\n * clicks\" means, and it cannot be produced from the delivery log without\n * reading every recipient row for the campaign.\n *\n * So it is a WRITE-TIME rollup: one document per campaign,\n * `campaigns/{campaignId}/reports/links`, holding a bounded map. The report\n * reads exactly one document for the whole table.\n *=========================================*/\n\n/**\n * How many distinct destinations one campaign's rollup keeps.\n *\n * A CAP rather than a page size, and it exists because the map lives in a\n * single document with a 1 MiB ceiling. Clicks past the cap are counted in\n * {@link CampaignLinkRollup.overflowClicks} rather than dropped, so the\n * table's total still reconciles with `stats.clicks`.\n */\nexport const CAMPAIGN_LINK_ROLLUP_MAX = 50\n\n/**\n * Reduces a clicked URL to the key the rollup counts under.\n *\n * ## Why the query string is dropped\n *\n * Two reasons, and the second is the one that forces it:\n *\n * 1. **A campaign body goes through `resolveMergeTags` per recipient**, so a\n * link may carry a personalised query. Keying on the full URL would then\n * mint one rollup row per RECIPIENT — the aggregate degenerates into the\n * per-recipient log it exists to summarise, and it blows the cap on the\n * first campaign that does it.\n * 2. **A personalised query can carry the recipient's own address.** The\n * rollup is an aggregate read by everyone on the site's team; it must not\n * become a list of who clicked, and dropping the query is what guarantees\n * it cannot.\n *\n * ⚠️ The cost is real and is stated on the screen: two links to the same page\n * distinguished only by their UTM parameters count as ONE row. That is a\n * known limitation, not an oversight — see the note in the report card.\n *\n * @returns the normalized URL, or `null` for anything unparseable or not\n * http(s). A rollup key must be a URL a merchant recognises.\n */\nexport function campaignLinkKey(link: string | null | undefined): string | null {\n const raw = String(link ?? '').trim()\n if (!raw) return null\n try {\n const url = new URL(raw)\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n // Trailing slash normalised away so `/pricing` and `/pricing/` are one\n // row; the bare origin keeps its slash so the key is still a valid URL.\n const path = url.pathname.length > 1\n ? url.pathname.replace(/\\/+$/, '')\n : url.pathname\n return `${url.origin}${path}`\n } catch {\n return null\n }\n}\n\n/** One destination in the rollup. */\nexport interface CampaignLinkRow {\n url: string\n /** Click EVENTS on this destination. One reader clicking twice counts two. */\n clicks: number\n /** Share of the campaign's counted link clicks. */\n share: CampaignRate | null\n}\n\n/** The stored shape of `campaigns/{campaignId}/reports/links`. */\nexport interface CampaignLinkRollup {\n links?: Record<string, { url?: string; clicks?: number }>\n /** Clicks on destinations past {@link CAMPAIGN_LINK_ROLLUP_MAX}. */\n overflowClicks?: number\n /** Click events that arrived carrying no destination at all. */\n unattributedClicks?: number\n}\n\n/** What the link table renders. */\nexport interface CampaignLinkReport {\n rows: CampaignLinkRow[]\n /** Clicks counted against a named destination — the table's total. */\n attributedClicks: number\n overflowClicks: number\n unattributedClicks: number\n /** True once the cap bit, so the table says it is not the whole list. */\n truncated: boolean\n}\n\n/**\n * The link table, sorted by clicks descending.\n *\n * `share` is over ATTRIBUTED clicks — the clicks this table accounts for —\n * and not over `stats.clicks`. The two differ by the overflow and the\n * unattributed, and a share column that did not sum to 100% because of rows\n * that are not on screen is the kind of arithmetic a reader cannot check.\n * Both excluded figures are returned so the screen can state them.\n */\nexport function campaignLinkReport(\n rollup: CampaignLinkRollup | undefined,\n): CampaignLinkReport {\n const entries = Object.values(rollup?.links ?? {})\n .map((entry) => ({\n url: String(entry?.url ?? ''),\n clicks: Number(entry?.clicks ?? 0),\n }))\n .filter((entry) => entry.url && Number.isFinite(entry.clicks))\n const attributedClicks = entries.reduce((total, one) => total + one.clicks, 0)\n const rows = [...entries]\n .sort((a, b) => b.clicks - a.clicks || a.url.localeCompare(b.url))\n .map((entry) => ({\n ...entry,\n share: campaignRate(entry.clicks, attributedClicks, 'link clicks counted'),\n }))\n const overflowClicks = Number(rollup?.overflowClicks ?? 0)\n return {\n rows,\n attributedClicks,\n overflowClicks,\n unattributedClicks: Number(rollup?.unattributedClicks ?? 0),\n truncated: entries.length >= CAMPAIGN_LINK_ROLLUP_MAX || overflowClicks > 0,\n }\n}\n\n/*==========================================\n * WHAT THE CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign the way a form does — `campaignIds` on its own\n * document — and its outcomes are then the campaign's to report: every\n * person enrolled, the first email each one was sent, the replies, the\n * meetings booked from a sequence link, and the enrolled leads that\n * converted. The Outreach runtime credits each outcome once per enrollment\n * to every campaign the sequence was in when the person was enrolled, into\n * `hosts/{hostId}/campaignSequenceReports/{campaignId}` — one document per\n * campaign, beside the conversions rollup and never inside the campaign\n * document, which the history list and the glance widget read.\n *\n * ## The five figures are a funnel, not a sum\n *\n * Each figure counts ENROLLMENTS at a stage: 40 enrolled, 38 sent, 6\n * replied, 2 meetings, 1 converted. They are read across, never added — a\n * person is in every stage they reached — and the model keeps them as an\n * ordered list rather than a total for the reason the conversion kinds are\n * kept apart.\n *\n * ## Absent is not zero\n *\n * A campaign no sequence was ever in has no document, and a stage nobody\n * reached has no field. Both are `null` here, so the screen can say\n * \"nothing recorded\" for the first and draw a dash for the second, rather\n * than printing a measured 0 replies for a campaign whose sequence went\n * out yesterday.\n *=========================================*/\n\n/**\n * The outcomes the runtime credits, in funnel order. An array first, because\n * the ORDER is the reading order and a second list is a second chance to\n * leave one out; the union is derived from it.\n */\nexport const CAMPAIGN_SEQUENCE_OUTCOMES = [\n 'enrolled',\n 'sent',\n 'replied',\n 'meetings',\n 'converted',\n] as const\n\nexport type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number]\n\n/**\n * The org collection holding one document per campaign container,\n * `orgs/{orgId}/campaignSequenceReports/{campaignId}`.\n */\nexport const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports'\n\n/** What a reader calls each outcome, and what the count means. */\nexport const CAMPAIGN_SEQUENCE_OUTCOME_COPY: Readonly<\n Record<CampaignSequenceOutcome, { label: string; note: string }>\n> = {\n enrolled: { label: 'Enrolled', note: 'people enrolled in a sequence in this campaign' },\n sent: { label: 'Sent', note: 'of them, sent their first sequence email' },\n replied: { label: 'Replied', note: 'of them, who wrote back' },\n meetings: { label: 'Meetings', note: 'bookings made from a sequence link' },\n converted: { label: 'Converted', note: 'enrolled leads that became contacts' },\n}\n\n/**\n * The stored shape of `campaignSequenceReports/{campaignId}`.\n *\n * Read-side only, every field optional: another plugin's runtime wrote it,\n * and a reader that assumed a field was present would throw on the first\n * document written before the field existed.\n */\nexport interface CampaignSequencesRollup {\n byOutcome?: Partial<Record<CampaignSequenceOutcome, number>>\n /** When the runtime last credited anything, epoch ms. */\n updatedAtMs?: number\n}\n\n/** One outcome's figure on screen. */\nexport interface CampaignSequenceFigure {\n outcome: CampaignSequenceOutcome\n label: string\n /** The count, or `null` when the rollup holds no entry for the outcome. */\n value: number | null\n note: string\n}\n\n/** Everything the Sequences section renders. */\nexport interface CampaignSequencesReport {\n /** Always all five, always in {@link CAMPAIGN_SEQUENCE_OUTCOMES} order. */\n figures: CampaignSequenceFigure[]\n /** Whether the rollup document exists at all — see the block header. */\n recorded: boolean\n /** At least one outcome holds a figure. */\n any: boolean\n caveats: CampaignCaveat[]\n}\n\n/** Turns the stored rollup into the Sequences section. */\nexport function campaignSequencesReport(\n rollup: CampaignSequencesRollup | undefined,\n): CampaignSequencesReport {\n const stored = rollup?.byOutcome ?? {}\n const figures: CampaignSequenceFigure[] = CAMPAIGN_SEQUENCE_OUTCOMES.map((outcome) => {\n const raw = stored[outcome]\n const value = Math.floor(Number(raw ?? 0))\n return {\n outcome,\n label: CAMPAIGN_SEQUENCE_OUTCOME_COPY[outcome].label,\n // Unrecorded, negative and non-numeric all read as \"no figure\": the\n // runtime only ever increments, so a stored 0 is not a count either.\n value: raw === undefined || !Number.isFinite(value) || value <= 0 ? null : value,\n note: CAMPAIGN_SEQUENCE_OUTCOME_COPY[outcome].note,\n }\n })\n const any = figures.some((figure) => figure.value !== null)\n const caveats: CampaignCaveat[] = any\n ? [\n {\n id: 'sequences-funnel-not-summed',\n message:\n 'Each figure counts the people who reached that stage, so a person ' +\n 'who replied is counted under Enrolled and Sent as well. Read them ' +\n 'across; they are deliberately not added together.',\n },\n ]\n : []\n return { figures, recorded: rollup !== undefined, any, caveats }\n}\n"],"names":["campaignRate","numerator","denominator","denominatorLabel","top","Number","bottom","isFinite","value","campaignReport","stats","source","sent","recipients","opens","clicks","bounced","complained","unsubscribes","delivered","undefined","uniqueOpens","uniqueClicks","clickTrackable","clickTracked","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","audienceSize","populations","addPopulation","id","label","count","ofLabel","of","push","consented","consentedByOperator","grandfathered","consentWithheld","suppressed","cadenceHeld","noMailServer","gatewayHeld","caveats","message","audienceSizeTruncated","deferred","CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","link","raw","String","trim","url","URL","protocol","path","pathname","length","replace","origin","campaignLinkReport","rollup","entries","Object","values","links","map","entry","filter","attributedClicks","reduce","total","one","rows","sort","a","b","localeCompare","share","overflowClicks","unattributedClicks","truncated","CAMPAIGN_SEQUENCE_OUTCOMES","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","CAMPAIGN_SEQUENCE_OUTCOME_COPY","enrolled","note","replied","meetings","converted","campaignSequencesReport","stored","byOutcome","figures","outcome","Math","floor","any","some","figure","recorded"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/campaign-report.ts"],"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 * CAMPAIGN REPORTING MATH — the only place a rate is computed.\n *\n * ## Why a pure module and not a component\n *\n * Every number on the report screen is a division, and a division is where\n * email reporting goes wrong. Putting the arithmetic in JSX means the\n * denominator is chosen by whoever writes the next card, in a file nobody\n * tests for arithmetic; putting it here means each rate is named once,\n * carries its own denominator as data, and is provable.\n *\n * ## The rule this module exists to enforce\n *\n * **A rate is a triple — numerator, denominator, and the NAME of the\n * denominator — or it is not reported.** An open rate over `sent` and an open\n * rate over `delivered` are different numbers with the same label, and the\n * gap between them is exactly the mail that bounced. The industry convention\n * is over `delivered`, and a report that quietly used `sent` would read\n * higher than the same campaign measured anywhere else.\n *\n * So {@link CampaignRate} carries `denominatorLabel`, and the screen is\n * required to render it. There is no overload that omits it.\n *\n * ## Why some rates are deliberately absent\n *\n * {@link campaignRate} answers `null`, not zero, when it cannot divide:\n *\n * - **A zero denominator.** 0 opens out of 0 delivered is not a 0% open\n * rate, it is no open rate. Rendering 0% invites the reader to compare it\n * with a campaign that really did fail.\n * - **An UNKNOWN denominator.** `delivered` is counted by the delivery\n * webhook, which was connected after some campaigns were sent. A campaign\n * with 400 sends and no delivery events has an unknown denominator, not a\n * denominator of zero — and dividing by `sent` instead is precisely the\n * flattering substitution above.\n *\n * ## The structural-zero window\n *\n * Click tracking rewrites links in the HTML part. Sends that carried no HTML\n * part were therefore untrackable, and every one of those campaigns reports 0\n * clicks whatever the recipients actually did — a real 0 and a structural 0\n * rendered identically. `send-email.ts` now synthesises an HTML part for a\n * text-only send, so every send after that carries one, and\n * `campaign-send.ts` records {@link CampaignStats.clickTracked} to say so.\n *\n * A campaign with no such marker predates the record. Its click COUNT is\n * still shown — it is a real count of real events — but no click RATE is\n * computed from it, because a rate presents the number as a measurement of\n * the audience and for those campaigns it is a measurement of the sender.\n */\n\n/**\n * The `stats` map on `hosts/{hostId}/campaigns/{campaignId}`.\n *\n * Every field is optional and every reader defaults it, because these are\n * written by three different writers at three different times — the send, the\n * delivery webhook, the unsubscribe handler — and a campaign is a legitimate,\n * readable document from the instant the first of them lands.\n */\nexport interface CampaignStats {\n /*========================================\n * WRITTEN BY THE SEND. Never recomputed.\n *\n * These are the truth of what happened, recorded once by the code that did\n * it. Re-deriving any of them at read time would produce a number that\n * disagrees with the send — the audience has moved on since, suppressions\n * have been added, consent has been recorded — and the recorded one is the\n * one that describes the campaign.\n *=======================================*/\n /** The whole audience the send was taken from, before the per-send cap. */\n audienceSize?: number\n /** `audienceSize` is a FLOOR: audience resolution hit its read ceiling. */\n audienceSizeTruncated?: boolean\n /** Addresses this send ADDRESSED — the audience after the per-send cap. */\n recipients?: number\n /** Messages the provider accepted. The `sent` in \"sent/recipients\". */\n sent?: number\n /** Of the audience, how many carry a recorded marketing consent basis. */\n consented?: number\n /** Of `consented`, how many hold a basis an operator asserted for them. */\n consentedByOperator?: number\n /** Of the audience, how many are reachable only because enforcement is\n * not retroactive — the population a strict consent policy removes. */\n grandfathered?: number\n /** Of the audience, how many the consent rule refused to mail. */\n consentWithheld?: number\n /** Of `recipients`, how many were already suppressed (unsubscribed,\n * bounced or complained on an earlier send). */\n suppressed?: number\n /** Of `recipients`, how many asked this site for mail less often than this\n * send would have arrived. Still subscribed; reached by a later campaign. */\n cadenceHeld?: number\n /**\n * Of `recipients`, how many the deliverability check took out (AGL-3328):\n * their domain has no mail server, or their mail gateway refused this\n * sending domain twice in thirty days and delivered nothing.\n */\n noMailServer?: number\n gatewayHeld?: number\n /** Recipients the hourly send governor refused mid-batch. */\n deferred?: number\n /**\n * This send carried an HTML part, so its links were trackable.\n *\n * Absent on every campaign sent before the field existed — see the\n * structural-zero note in this module's header. Absent is NOT false; it is\n * \"not recorded\", and the report says so rather than guessing.\n */\n clickTracked?: boolean\n /** Per-variant send counts for an A/B campaign. */\n variantSends?: Record<string, number>\n\n /*========================================\n * WRITTEN BY THE DELIVERY WEBHOOK, as increments.\n *=======================================*/\n /** Messages the receiving server accepted. The rate DENOMINATOR. */\n delivered?: number\n /** Open EVENTS. One reader opening four times counts four. */\n opens?: number\n /** Click EVENTS. One reader clicking three links counts three. */\n clicks?: number\n /** Messages whose FIRST open was seen — distinct readers who opened. */\n uniqueOpens?: number\n /** Messages whose FIRST click was seen — distinct readers who clicked. */\n uniqueClicks?: number\n /** Messages that bounced, permanent and transient together. */\n bounced?: number\n /** Recipients who pressed \"report spam\". */\n complained?: number\n\n /*========================================\n * WRITTEN BY THE UNSUBSCRIBE HANDLER.\n *=======================================*/\n /** Recipients who unsubscribed through THIS campaign's link. */\n unsubscribes?: number\n}\n\n/**\n * One rate, with the denominator it was taken over named as data.\n *\n * `denominatorLabel` is not a display nicety. It is the field that makes two\n * numbers called \"open rate\" distinguishable, and the screen renders it\n * beside the percentage for that reason.\n */\nexport interface CampaignRate {\n /** 0–1. Multiply for display; the model never formats. */\n value: number\n numerator: number\n denominator: number\n /** Reader-facing name of the denominator, e.g. `'delivered'`. */\n denominatorLabel: string\n}\n\n/**\n * A rate, or `null` when one cannot honestly be taken.\n *\n * `null` on a zero or unknown denominator — see the module header for why\n * that is not the same as 0%.\n */\nexport function campaignRate(\n numerator: number | undefined,\n denominator: number | undefined,\n denominatorLabel: string,\n): CampaignRate | null {\n const top = Number(numerator ?? 0)\n const bottom = Number(denominator ?? 0)\n if (!Number.isFinite(top) || !Number.isFinite(bottom)) return null\n if (bottom <= 0) return null\n return {\n value: top / bottom,\n numerator: top,\n denominator: bottom,\n denominatorLabel,\n }\n}\n\n/** Why a number the report would otherwise show is being withheld. */\nexport interface CampaignCaveat {\n /** Stable id, so a spec can assert on the caveat rather than its prose. */\n id:\n | 'delivery-unrecorded'\n | 'click-tracking-unrecorded'\n | 'audience-truncated'\n | 'send-deferred'\n /* Raised by `campaign-revenue.ts`, which reports through this shape so\n * the screen has one way of saying \"a number is being withheld and here\n * is why\" rather than one per section. */\n | 'revenue-denominator-unrecorded'\n | 'revenue-multi-currency'\n | 'revenue-mid-flight'\n | 'revenue-mixed-model'\n /* Raised by `campaign-conversions.ts`, through this shape for the same\n * reason: one way of saying \"a number is being withheld, or must not be\n * read the obvious way, and here is why\". The first two are the reasons\n * the four conversion kinds stand apart instead of totalling; the last\n * two qualify the uncredited figure rather than withholding it. */\n | 'conversions-kinds-overlap'\n | 'conversions-web-not-rolled-up'\n | 'conversions-unattributed-is-a-ceiling'\n | 'conversions-total-crosses-hosts'\n /* Raised by the Sequences block below (AGL-3254): the five figures are\n * a funnel read across, never a sum. */\n | 'sequences-funnel-not-summed'\n message: string\n}\n\n/** One population the send measured, for the audience breakdown. */\nexport interface CampaignPopulation {\n id: string\n label: string\n count: number\n /** What this count is a part OF, named. */\n ofLabel: string\n of: number\n}\n\n/** Everything the report screen renders, decided here rather than in JSX. */\nexport interface CampaignReport {\n sent: number\n recipients: number\n delivered: number | null\n opens: number\n clicks: number\n uniqueOpens: number | null\n uniqueClicks: number | null\n bounced: number\n complained: number\n unsubscribes: number\n /** Rates, each `null` when its denominator is zero or unrecorded. */\n rates: {\n /** Accepted by the receiving server, over what the provider accepted. */\n delivery: CampaignRate | null\n /** Distinct readers who opened, over delivered. */\n open: CampaignRate | null\n /** Distinct readers who clicked, over delivered. */\n click: CampaignRate | null\n /**\n * Distinct clickers over distinct OPENERS — a different question from\n * `click`, and the one the two get confused for. It answers \"of the\n * people who read it, how many acted\", not \"of the people who received\n * it\". Reported separately and labelled separately, never as \"click\n * rate\".\n */\n clickToOpen: CampaignRate | null\n /** Bounced over what the provider accepted. */\n bounce: CampaignRate | null\n /** Complaints over delivered — the number mailbox providers judge on. */\n complaint: CampaignRate | null\n /** Unsubscribes through this campaign's link, over delivered. */\n unsubscribe: CampaignRate | null\n }\n populations: CampaignPopulation[]\n caveats: CampaignCaveat[]\n}\n\n/**\n * Turns a stored `stats` map into the report.\n *\n * ## The denominator decisions, in one place\n *\n * - **`delivered`** carries the engagement rates — open, click, complaint,\n * unsubscribe. Mail that bounced was never in front of a human, so\n * including it in the denominator of an open rate depresses a number that\n * describes the audience with a fact about the address list. This is also\n * the convention every other tool reports, which matters: a merchant\n * comparing our figure with their previous ESP's must be comparing the same\n * quantity.\n * - **`sent`** carries the delivery and bounce rates, because those describe\n * what happened to what we handed the provider, and `delivered` is the\n * numerator of one of them — a rate cannot be over itself.\n * - **`uniqueOpens`** is the open-rate numerator, not `opens`. Open EVENTS\n * over recipients can exceed 100% the moment one person opens twice, and a\n * percentage above 100 is how a reader learns the number means something\n * other than what it says. Both are shown; only the distinct count is\n * divided.\n *\n * ## `delivered` unknown vs. zero\n *\n * A campaign predating the delivery webhook records no `delivered` at all.\n * That is reported as `null` and every rate over it is withheld, with a\n * caveat naming the reason — rather than substituting `sent`, which would\n * silently publish the flattered number this module exists to refuse.\n */\nexport function campaignReport(stats: CampaignStats | undefined): CampaignReport {\n const source = stats ?? {}\n const sent = Number(source.sent ?? 0)\n const recipients = Number(source.recipients ?? 0)\n const opens = Number(source.opens ?? 0)\n const clicks = Number(source.clicks ?? 0)\n const bounced = Number(source.bounced ?? 0)\n const complained = Number(source.complained ?? 0)\n const unsubscribes = Number(source.unsubscribes ?? 0)\n\n /*\n * ABSENT, not zero. `stats.delivered` is written only by the delivery\n * webhook, so `undefined` means \"no delivery event has ever been recorded\n * for this campaign\" — which for an old campaign means the webhook was not\n * connected, and for a campaign sent thirty seconds ago means the events\n * are still in flight. Neither is \"nothing was delivered\", and both are\n * ruined by `?? 0`, which would turn the unknown into a hard zero and make\n * every rate over it `null` for the RIGHT answer by the WRONG reasoning —\n * and would render \"0 delivered\" on screen beside \"500 sent\".\n */\n const delivered =\n source.delivered === undefined ? null : Number(source.delivered)\n const uniqueOpens =\n source.uniqueOpens === undefined ? null : Number(source.uniqueOpens)\n const uniqueClicks =\n source.uniqueClicks === undefined ? null : Number(source.uniqueClicks)\n\n /*\n * The click rate is withheld for a campaign that never recorded carrying an\n * HTML part, even when clicks are non-zero and `delivered` is known. See\n * the structural-zero note in the module header: for those sends 0 is the\n * only value the number could ever have taken, so a rate computed from it\n * measures our sending code rather than the recipients.\n */\n const clickTrackable = source.clickTracked === true\n\n const rates: CampaignReport['rates'] = {\n /*\n * `null` when `delivered` is UNRECORDED, and this is the one rate where\n * the numerator can be unknown rather than zero.\n *\n * Everywhere else an absent numerator is a genuine nought — a campaign\n * with delivery events and no opens really does have a 0% open rate, and\n * that is worth showing. Here the numerator IS the unrecorded quantity,\n * so `campaignRate(undefined, 1000, 'sent')` would divide a missing\n * measurement by a real one and publish \"0.0% delivery rate — 0 of 1,000\n * sent\" for a campaign whose delivery events were merely never recorded.\n * That is the flattering-substitution failure this module exists to\n * refuse, running in the other direction: not a rate that reads too high,\n * but a campaign that reads as a total delivery failure.\n */\n delivery:\n delivered === null ? null : campaignRate(delivered, sent, 'sent'),\n open: campaignRate(uniqueOpens ?? undefined, delivered ?? undefined, 'delivered'),\n click: clickTrackable\n ? campaignRate(uniqueClicks ?? undefined, delivered ?? undefined, 'delivered')\n : null,\n clickToOpen: clickTrackable\n ? campaignRate(uniqueClicks ?? undefined, uniqueOpens ?? undefined, 'unique openers')\n : null,\n bounce: campaignRate(bounced, sent, 'sent'),\n complaint: campaignRate(complained, delivered ?? undefined, 'delivered'),\n unsubscribe: campaignRate(unsubscribes, delivered ?? undefined, 'delivered'),\n }\n\n /*\n * The populations the SEND measured, reported as parts of a named whole\n * rather than as bare counts. \"412 withheld\" invites the question \"out of\n * what\"; the answer is the audience, and it is a different whole from the\n * one `suppressed` is measured against — consent is decided over the whole\n * audience and suppression over the capped recipient list, because that is\n * where each check runs. Netting them into one column would present two\n * different denominators as one.\n */\n const audienceSize = Number(source.audienceSize ?? 0)\n const populations: CampaignPopulation[] = []\n const addPopulation = (\n id: string,\n label: string,\n count: number | undefined,\n ofLabel: string,\n of: number,\n ) => {\n if (count === undefined) return\n populations.push({ id, label, count: Number(count), ofLabel, of })\n }\n addPopulation(\n 'consented',\n 'Had a consent basis',\n source.consented,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'consentedByOperator',\n 'Consent asserted by an operator',\n source.consentedByOperator,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'grandfathered',\n 'Reachable only because consent is not enforced retroactively',\n source.grandfathered,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'consentWithheld',\n 'Withheld by the consent rule',\n source.consentWithheld,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'suppressed',\n 'Already suppressed',\n source.suppressed,\n 'addressed',\n recipients,\n )\n addPopulation(\n 'cadenceHeld',\n 'Asked for mail less often than this',\n source.cadenceHeld,\n 'addressed',\n recipients,\n )\n addPopulation(\n 'noMailServer',\n 'Excluded: no mail server',\n source.noMailServer,\n 'addressed',\n recipients,\n )\n addPopulation(\n 'gatewayHeld',\n 'Excluded: behind a gateway that refused this sender',\n source.gatewayHeld,\n 'addressed',\n recipients,\n )\n\n const caveats: CampaignCaveat[] = []\n if (delivered === null) {\n caveats.push({\n id: 'delivery-unrecorded',\n message:\n 'No delivery events have been recorded for this campaign, so open, ' +\n 'click, complaint and unsubscribe rates cannot be computed — every ' +\n 'one of them is taken over delivered. Counts below are still real.',\n })\n }\n if (!clickTrackable) {\n caveats.push({\n id: 'click-tracking-unrecorded',\n message:\n 'This send did not record carrying an HTML part. Click tracking ' +\n 'rewrites links in the HTML, so a send without one reports zero ' +\n 'clicks whatever recipients did. The click count is shown; no click ' +\n 'rate is computed from it.',\n })\n }\n if (source.audienceSizeTruncated) {\n caveats.push({\n id: 'audience-truncated',\n message:\n 'Audience resolution stopped at its read ceiling, so the audience ' +\n 'size is a floor — the real audience is at least this large, and ' +\n 'every share taken over it is at most the figure shown.',\n })\n }\n if (Number(source.deferred ?? 0) > 0) {\n caveats.push({\n id: 'send-deferred',\n message:\n `${Number(source.deferred)} recipients were held back by the hourly ` +\n 'send governor and never received this campaign. They are counted in ' +\n 'addressed, not in sent.',\n })\n }\n\n return {\n sent,\n recipients,\n delivered,\n opens,\n clicks,\n uniqueOpens,\n uniqueClicks,\n bounced,\n complained,\n unsubscribes,\n rates,\n populations,\n caveats,\n }\n}\n\n/*==========================================\n * LINK-LEVEL CLICKS.\n *\n * Resend's `email.clicked` payload carries `data.click.link`, the destination\n * the recipient followed; the mail provider's `deliveryEvents` already reads it\n * into `EmailDeliveryEvent.link`, and the per-recipient delivery log already\n * stores it. What did not exist was an aggregate — which is what \"link\n * clicks\" means, and it cannot be produced from the delivery log without\n * reading every recipient row for the campaign.\n *\n * So it is a WRITE-TIME rollup: one document per campaign,\n * `campaigns/{campaignId}/reports/links`, holding a bounded map. The report\n * reads exactly one document for the whole table.\n *=========================================*/\n\n/**\n * How many distinct destinations one campaign's rollup keeps.\n *\n * A CAP rather than a page size, and it exists because the map lives in a\n * single document with a 1 MiB ceiling. Clicks past the cap are counted in\n * {@link CampaignLinkRollup.overflowClicks} rather than dropped, so the\n * table's total still reconciles with `stats.clicks`.\n */\nexport const CAMPAIGN_LINK_ROLLUP_MAX = 50\n\n/**\n * Reduces a clicked URL to the key the rollup counts under.\n *\n * ## Why the query string is dropped\n *\n * Two reasons, and the second is the one that forces it:\n *\n * 1. **A campaign body goes through `resolveMergeTags` per recipient**, so a\n * link may carry a personalised query. Keying on the full URL would then\n * mint one rollup row per RECIPIENT — the aggregate degenerates into the\n * per-recipient log it exists to summarise, and it blows the cap on the\n * first campaign that does it.\n * 2. **A personalised query can carry the recipient's own address.** The\n * rollup is an aggregate read by everyone on the site's team; it must not\n * become a list of who clicked, and dropping the query is what guarantees\n * it cannot.\n *\n * ⚠️ The cost is real and is stated on the screen: two links to the same page\n * distinguished only by their UTM parameters count as ONE row. That is a\n * known limitation, not an oversight — see the note in the report card.\n *\n * @returns the normalized URL, or `null` for anything unparseable or not\n * http(s). A rollup key must be a URL a merchant recognises.\n */\nexport function campaignLinkKey(link: string | null | undefined): string | null {\n const raw = String(link ?? '').trim()\n if (!raw) return null\n try {\n const url = new URL(raw)\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n // Trailing slash normalised away so `/pricing` and `/pricing/` are one\n // row; the bare origin keeps its slash so the key is still a valid URL.\n const path = url.pathname.length > 1\n ? url.pathname.replace(/\\/+$/, '')\n : url.pathname\n return `${url.origin}${path}`\n } catch {\n return null\n }\n}\n\n/** One destination in the rollup. */\nexport interface CampaignLinkRow {\n url: string\n /** Click EVENTS on this destination. One reader clicking twice counts two. */\n clicks: number\n /** Share of the campaign's counted link clicks. */\n share: CampaignRate | null\n}\n\n/** The stored shape of `campaigns/{campaignId}/reports/links`. */\nexport interface CampaignLinkRollup {\n links?: Record<string, { url?: string; clicks?: number }>\n /** Clicks on destinations past {@link CAMPAIGN_LINK_ROLLUP_MAX}. */\n overflowClicks?: number\n /** Click events that arrived carrying no destination at all. */\n unattributedClicks?: number\n}\n\n/** What the link table renders. */\nexport interface CampaignLinkReport {\n rows: CampaignLinkRow[]\n /** Clicks counted against a named destination — the table's total. */\n attributedClicks: number\n overflowClicks: number\n unattributedClicks: number\n /** True once the cap bit, so the table says it is not the whole list. */\n truncated: boolean\n}\n\n/**\n * The link table, sorted by clicks descending.\n *\n * `share` is over ATTRIBUTED clicks — the clicks this table accounts for —\n * and not over `stats.clicks`. The two differ by the overflow and the\n * unattributed, and a share column that did not sum to 100% because of rows\n * that are not on screen is the kind of arithmetic a reader cannot check.\n * Both excluded figures are returned so the screen can state them.\n */\nexport function campaignLinkReport(\n rollup: CampaignLinkRollup | undefined,\n): CampaignLinkReport {\n const entries = Object.values(rollup?.links ?? {})\n .map((entry) => ({\n url: String(entry?.url ?? ''),\n clicks: Number(entry?.clicks ?? 0),\n }))\n .filter((entry) => entry.url && Number.isFinite(entry.clicks))\n const attributedClicks = entries.reduce((total, one) => total + one.clicks, 0)\n const rows = [...entries]\n .sort((a, b) => b.clicks - a.clicks || a.url.localeCompare(b.url))\n .map((entry) => ({\n ...entry,\n share: campaignRate(entry.clicks, attributedClicks, 'link clicks counted'),\n }))\n const overflowClicks = Number(rollup?.overflowClicks ?? 0)\n return {\n rows,\n attributedClicks,\n overflowClicks,\n unattributedClicks: Number(rollup?.unattributedClicks ?? 0),\n truncated: entries.length >= CAMPAIGN_LINK_ROLLUP_MAX || overflowClicks > 0,\n }\n}\n\n/*==========================================\n * WHAT THE CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign the way a form does — `campaignIds` on its own\n * document — and its outcomes are then the campaign's to report: every\n * person enrolled, the first email each one was sent, the replies, the\n * meetings booked from a sequence link, and the enrolled leads that\n * converted. The Outreach runtime credits each outcome once per enrollment\n * to every campaign the sequence was in when the person was enrolled, into\n * `hosts/{hostId}/campaignSequenceReports/{campaignId}` — one document per\n * campaign, beside the conversions rollup and never inside the campaign\n * document, which the history list and the glance widget read.\n *\n * ## The five figures are a funnel, not a sum\n *\n * Each figure counts ENROLLMENTS at a stage: 40 enrolled, 38 sent, 6\n * replied, 2 meetings, 1 converted. They are read across, never added — a\n * person is in every stage they reached — and the model keeps them as an\n * ordered list rather than a total for the reason the conversion kinds are\n * kept apart.\n *\n * ## Absent is not zero\n *\n * A campaign no sequence was ever in has no document, and a stage nobody\n * reached has no field. Both are `null` here, so the screen can say\n * \"nothing recorded\" for the first and draw a dash for the second, rather\n * than printing a measured 0 replies for a campaign whose sequence went\n * out yesterday.\n *=========================================*/\n\n/**\n * The outcomes the runtime credits, in funnel order. An array first, because\n * the ORDER is the reading order and a second list is a second chance to\n * leave one out; the union is derived from it.\n */\nexport const CAMPAIGN_SEQUENCE_OUTCOMES = [\n 'enrolled',\n 'sent',\n 'replied',\n 'meetings',\n 'converted',\n] as const\n\nexport type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number]\n\n/**\n * The org collection holding one document per campaign container,\n * `orgs/{orgId}/campaignSequenceReports/{campaignId}`.\n */\nexport const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports'\n\n/** What a reader calls each outcome, and what the count means. */\nexport const CAMPAIGN_SEQUENCE_OUTCOME_COPY: Readonly<\n Record<CampaignSequenceOutcome, { label: string; note: string }>\n> = {\n enrolled: { label: 'Enrolled', note: 'people enrolled in a sequence in this campaign' },\n sent: { label: 'Sent', note: 'of them, sent their first sequence email' },\n replied: { label: 'Replied', note: 'of them, who wrote back' },\n meetings: { label: 'Meetings', note: 'bookings made from a sequence link' },\n converted: { label: 'Converted', note: 'enrolled leads that became contacts' },\n}\n\n/**\n * The stored shape of `campaignSequenceReports/{campaignId}`.\n *\n * Read-side only, every field optional: another plugin's runtime wrote it,\n * and a reader that assumed a field was present would throw on the first\n * document written before the field existed.\n */\nexport interface CampaignSequencesRollup {\n byOutcome?: Partial<Record<CampaignSequenceOutcome, number>>\n /** When the runtime last credited anything, epoch ms. */\n updatedAtMs?: number\n}\n\n/** One outcome's figure on screen. */\nexport interface CampaignSequenceFigure {\n outcome: CampaignSequenceOutcome\n label: string\n /** The count, or `null` when the rollup holds no entry for the outcome. */\n value: number | null\n note: string\n}\n\n/** Everything the Sequences section renders. */\nexport interface CampaignSequencesReport {\n /** Always all five, always in {@link CAMPAIGN_SEQUENCE_OUTCOMES} order. */\n figures: CampaignSequenceFigure[]\n /** Whether the rollup document exists at all — see the block header. */\n recorded: boolean\n /** At least one outcome holds a figure. */\n any: boolean\n caveats: CampaignCaveat[]\n}\n\n/** Turns the stored rollup into the Sequences section. */\nexport function campaignSequencesReport(\n rollup: CampaignSequencesRollup | undefined,\n): CampaignSequencesReport {\n const stored = rollup?.byOutcome ?? {}\n const figures: CampaignSequenceFigure[] = CAMPAIGN_SEQUENCE_OUTCOMES.map((outcome) => {\n const raw = stored[outcome]\n const value = Math.floor(Number(raw ?? 0))\n return {\n outcome,\n label: CAMPAIGN_SEQUENCE_OUTCOME_COPY[outcome].label,\n // Unrecorded, negative and non-numeric all read as \"no figure\": the\n // runtime only ever increments, so a stored 0 is not a count either.\n value: raw === undefined || !Number.isFinite(value) || value <= 0 ? null : value,\n note: CAMPAIGN_SEQUENCE_OUTCOME_COPY[outcome].note,\n }\n })\n const any = figures.some((figure) => figure.value !== null)\n const caveats: CampaignCaveat[] = any\n ? [\n {\n id: 'sequences-funnel-not-summed',\n message:\n 'Each figure counts the people who reached that stage, so a person ' +\n 'who replied is counted under Enrolled and Sent as well. Read them ' +\n 'across; they are deliberately not added together.',\n },\n ]\n : []\n return { figures, recorded: rollup !== undefined, any, caveats }\n}\n"],"names":["campaignRate","numerator","denominator","denominatorLabel","top","Number","bottom","isFinite","value","campaignReport","stats","source","sent","recipients","opens","clicks","bounced","complained","unsubscribes","delivered","undefined","uniqueOpens","uniqueClicks","clickTrackable","clickTracked","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","audienceSize","populations","addPopulation","id","label","count","ofLabel","of","push","consented","consentedByOperator","grandfathered","consentWithheld","suppressed","cadenceHeld","noMailServer","gatewayHeld","caveats","message","audienceSizeTruncated","deferred","CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","link","raw","String","trim","url","URL","protocol","path","pathname","length","replace","origin","campaignLinkReport","rollup","entries","Object","values","links","map","entry","filter","attributedClicks","reduce","total","one","rows","sort","a","b","localeCompare","share","overflowClicks","unattributedClicks","truncated","CAMPAIGN_SEQUENCE_OUTCOMES","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","CAMPAIGN_SEQUENCE_OUTCOME_COPY","enrolled","note","replied","meetings","converted","campaignSequencesReport","stored","byOutcome","figures","outcome","Math","floor","any","some","figure","recorded"],"mappings":"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"}
|
package/src/lib/model/index.d.ts
CHANGED
|
@@ -43,7 +43,6 @@ export * from './email-record';
|
|
|
43
43
|
* rolls its sends into one set of figures.
|
|
44
44
|
*/
|
|
45
45
|
export * from './campaign-container';
|
|
46
|
-
export * from './campaign-send-time';
|
|
47
46
|
/**
|
|
48
47
|
* What a campaign CAUSED: the four identify moments, kept apart because one
|
|
49
48
|
* visitor action writes several of them. Separate from the revenue module
|
package/src/lib/model/index.js
CHANGED
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
* The campaign CONTAINER — its window, its lists, and the arithmetic that
|
|
38
38
|
* rolls its sends into one set of figures.
|
|
39
39
|
*/ export * from "./campaign-container.js";
|
|
40
|
-
export * from "./campaign-send-time.js";
|
|
41
40
|
/**
|
|
42
41
|
* What a campaign CAUSED: the four identify moments, kept apart because one
|
|
43
42
|
* visitor action writes several of them. Separate from the revenue module
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/index.ts"],"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 email-campaign document model: the stored shapes and the pure\n * arithmetic over them.\n *\n * Nothing here imports React or MUI, so a server handler that needs a field\n * name or a rate can take this barrel without dragging a component graph\n * into its bundle. The renderers live one directory over, behind\n * `@aglyn/shared-ui-jsx/components/measured-figures.component`.\n */\n\n/**\n * Campaign reporting: the rate math, the populations the send recorded, and\n * the link rollup — pure, so every denominator is named once and provable\n * rather than chosen in JSX.\n */\nexport * from './campaign-report'\n\n/**\n * What a campaign EARNED: the last-click window, the gross/refunded pair and\n * the per-currency buckets that are never added together. Separate from the\n * rate math because it reads a different document and answers the merchant's\n * second question rather than their first.\n */\nexport * from './campaign-revenue'\n\n/** Reading one message record — its state, and when it went out. */\nexport * from './email-record'\n\n/**\n * The campaign CONTAINER — its window, its lists, and the arithmetic that\n * rolls its sends into one set of figures.\n */\nexport * from './campaign-container'\n\
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/index.ts"],"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 email-campaign document model: the stored shapes and the pure\n * arithmetic over them.\n *\n * Nothing here imports React or MUI, so a server handler that needs a field\n * name or a rate can take this barrel without dragging a component graph\n * into its bundle. The renderers live one directory over, behind\n * `@aglyn/shared-ui-jsx/components/measured-figures.component`.\n */\n\n/**\n * Campaign reporting: the rate math, the populations the send recorded, and\n * the link rollup — pure, so every denominator is named once and provable\n * rather than chosen in JSX.\n */\nexport * from './campaign-report'\n\n/**\n * What a campaign EARNED: the last-click window, the gross/refunded pair and\n * the per-currency buckets that are never added together. Separate from the\n * rate math because it reads a different document and answers the merchant's\n * second question rather than their first.\n */\nexport * from './campaign-revenue'\n\n/** Reading one message record — its state, and when it went out. */\nexport * from './email-record'\n\n/**\n * The campaign CONTAINER — its window, its lists, and the arithmetic that\n * rolls its sends into one set of figures.\n */\nexport * from './campaign-container'\n\n/**\n * What a campaign CAUSED: the four identify moments, kept apart because one\n * visitor action writes several of them. Separate from the revenue module\n * because it reads a different document and answers what happened to people\n * who were anonymous until the moment being counted.\n */\nexport * from './campaign-conversions'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;CAQC,GAED;;;;CAIC,GACD,cAAc,uBAAmB;AAEjC;;;;;CAKC,GACD,cAAc,wBAAoB;AAElC,kEAAkE,GAClE,cAAc,oBAAgB;AAE9B;;;CAGC,GACD,cAAc,0BAAsB;AAEpC;;;;;CAKC,GACD,cAAc,4BAAwB"}
|
|
@@ -1,74 +0,0 @@
|
|
|
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
|
-
* WHEN A LIST READS ITS MAIL: a suggested send time, taken from the sends that
|
|
19
|
-
* already went to it.
|
|
20
|
-
*
|
|
21
|
-
* A rule and not a guess. Each past send is put in the slot it went out in —
|
|
22
|
-
* its weekday and hour, in one named time zone — and the slot whose sends were
|
|
23
|
-
* opened by the largest share of the people they reached is the suggestion.
|
|
24
|
-
* The share is POOLED across a slot's sends (every unique open over every
|
|
25
|
-
* delivered message) rather than averaged, so one small send that happened to
|
|
26
|
-
* be opened by all nine of its recipients does not outrank a slot that reached
|
|
27
|
-
* thousands.
|
|
28
|
-
*
|
|
29
|
-
* Only sends that can be measured are counted: a send with no delivered count
|
|
30
|
-
* large enough to say anything is left out, and so is one whose opens exceed
|
|
31
|
-
* its deliveries, which is a record that disagrees with itself. With fewer
|
|
32
|
-
* measured sends than the minimum there is no suggestion at all — "not enough
|
|
33
|
-
* history" is an answer a composer can say, and a slot picked from one send is
|
|
34
|
-
* not.
|
|
35
|
-
*
|
|
36
|
-
* Pure, with the reads left to the caller that holds the credentials.
|
|
37
|
-
*/
|
|
38
|
-
/** One past send to the list, as the suggestion needs it. */
|
|
39
|
-
export interface CampaignSendTimeSample {
|
|
40
|
-
/** When the send went out. */
|
|
41
|
-
sentAtMs: number;
|
|
42
|
-
/** Messages the provider delivered. */
|
|
43
|
-
delivered: number;
|
|
44
|
-
/** Distinct recipients who opened it. */
|
|
45
|
-
uniqueOpens: number;
|
|
46
|
-
}
|
|
47
|
-
export interface CampaignSendTimeSuggestion {
|
|
48
|
-
/** 0 for Sunday through 6 for Saturday, in `timeZone`. */
|
|
49
|
-
weekday: number;
|
|
50
|
-
/** 0 to 23, in `timeZone`. */
|
|
51
|
-
hour: number;
|
|
52
|
-
/** The IANA zone the slot is stated in. */
|
|
53
|
-
timeZone: string;
|
|
54
|
-
/** The pooled unique-open rate of the sends in the slot, 0 to 1. */
|
|
55
|
-
openRate: number;
|
|
56
|
-
/** Measured sends that went out in the slot. */
|
|
57
|
-
sends: number;
|
|
58
|
-
/** Measured sends the slot was chosen among. */
|
|
59
|
-
measured: number;
|
|
60
|
-
}
|
|
61
|
-
/** The fewest measured sends a suggestion is taken from. */
|
|
62
|
-
export declare const CAMPAIGN_SEND_TIME_MIN_SENDS = 3;
|
|
63
|
-
/** The fewest delivered messages that make a send measurable. */
|
|
64
|
-
export declare const CAMPAIGN_SEND_TIME_MIN_DELIVERED = 20;
|
|
65
|
-
/**
|
|
66
|
-
* The slot the list's past sends were opened most in, or `null` when too few
|
|
67
|
-
* sends can be measured. An unknown time zone is read as UTC, and the answer
|
|
68
|
-
* says so.
|
|
69
|
-
*/
|
|
70
|
-
export declare function suggestCampaignSendTime(samples: ReadonlyArray<CampaignSendTimeSample>, options?: {
|
|
71
|
-
timeZone?: string;
|
|
72
|
-
}): CampaignSendTimeSuggestion | null;
|
|
73
|
-
/** The slot as a person reads it: "Tuesdays around 9 AM (UTC)". */
|
|
74
|
-
export declare function campaignSendTimeLabel(suggestion: Pick<CampaignSendTimeSuggestion, 'weekday' | 'hour' | 'timeZone'>): string;
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
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
|
-
*/ import { campaignRate } from "./campaign-report.js";
|
|
18
|
-
/** The fewest measured sends a suggestion is taken from. */ export const CAMPAIGN_SEND_TIME_MIN_SENDS = 3;
|
|
19
|
-
/** The fewest delivered messages that make a send measurable. */ export const CAMPAIGN_SEND_TIME_MIN_DELIVERED = 20;
|
|
20
|
-
const WEEKDAYS = {
|
|
21
|
-
Sun: 0,
|
|
22
|
-
Mon: 1,
|
|
23
|
-
Tue: 2,
|
|
24
|
-
Wed: 3,
|
|
25
|
-
Thu: 4,
|
|
26
|
-
Fri: 5,
|
|
27
|
-
Sat: 6
|
|
28
|
-
};
|
|
29
|
-
const WEEKDAY_NAMES = [
|
|
30
|
-
'Sunday',
|
|
31
|
-
'Monday',
|
|
32
|
-
'Tuesday',
|
|
33
|
-
'Wednesday',
|
|
34
|
-
'Thursday',
|
|
35
|
-
'Friday',
|
|
36
|
-
'Saturday'
|
|
37
|
-
];
|
|
38
|
-
function slotFormatter(timeZone) {
|
|
39
|
-
try {
|
|
40
|
-
return new Intl.DateTimeFormat('en-US', {
|
|
41
|
-
timeZone,
|
|
42
|
-
weekday: 'short',
|
|
43
|
-
hour: 'numeric',
|
|
44
|
-
hourCycle: 'h23'
|
|
45
|
-
});
|
|
46
|
-
} catch (unused) {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
/** The weekday and hour an instant falls in, in a zone. */ function slotOf(formatter, atMs) {
|
|
51
|
-
let weekday;
|
|
52
|
-
let hour;
|
|
53
|
-
for (const part of formatter.formatToParts(new Date(atMs))){
|
|
54
|
-
if (part.type === 'weekday') weekday = WEEKDAYS[part.value];
|
|
55
|
-
if (part.type === 'hour') hour = Number(part.value) % 24;
|
|
56
|
-
}
|
|
57
|
-
return weekday === undefined || hour === undefined || !Number.isFinite(hour) ? null : {
|
|
58
|
-
weekday,
|
|
59
|
-
hour
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* The slot the list's past sends were opened most in, or `null` when too few
|
|
64
|
-
* sends can be measured. An unknown time zone is read as UTC, and the answer
|
|
65
|
-
* says so.
|
|
66
|
-
*/ export function suggestCampaignSendTime(samples, options = {}) {
|
|
67
|
-
var _slotFormatter;
|
|
68
|
-
var _options_timeZone;
|
|
69
|
-
const requested = ((_options_timeZone = options.timeZone) == null ? void 0 : _options_timeZone.trim()) || 'UTC';
|
|
70
|
-
const formatter = (_slotFormatter = slotFormatter(requested)) != null ? _slotFormatter : slotFormatter('UTC');
|
|
71
|
-
const timeZone = slotFormatter(requested) ? requested : 'UTC';
|
|
72
|
-
if (!formatter) return null;
|
|
73
|
-
const slots = new Map();
|
|
74
|
-
let measured = 0;
|
|
75
|
-
for (const sample of samples){
|
|
76
|
-
var _slots_get;
|
|
77
|
-
const delivered = Number(sample.delivered);
|
|
78
|
-
const opens = Number(sample.uniqueOpens);
|
|
79
|
-
if (!Number.isFinite(sample.sentAtMs) || sample.sentAtMs <= 0) continue;
|
|
80
|
-
if (!Number.isFinite(delivered) || delivered < CAMPAIGN_SEND_TIME_MIN_DELIVERED) continue;
|
|
81
|
-
if (!Number.isFinite(opens) || opens < 0 || opens > delivered) continue;
|
|
82
|
-
const slot = slotOf(formatter, sample.sentAtMs);
|
|
83
|
-
if (!slot) continue;
|
|
84
|
-
measured += 1;
|
|
85
|
-
const key = `${slot.weekday}:${slot.hour}`;
|
|
86
|
-
const held = (_slots_get = slots.get(key)) != null ? _slots_get : _extends({}, slot, {
|
|
87
|
-
delivered: 0,
|
|
88
|
-
opens: 0,
|
|
89
|
-
sends: 0
|
|
90
|
-
});
|
|
91
|
-
held.delivered += delivered;
|
|
92
|
-
held.opens += opens;
|
|
93
|
-
held.sends += 1;
|
|
94
|
-
slots.set(key, held);
|
|
95
|
-
}
|
|
96
|
-
if (measured < CAMPAIGN_SEND_TIME_MIN_SENDS) return null;
|
|
97
|
-
let best = null;
|
|
98
|
-
for (const slot of slots.values()){
|
|
99
|
-
const rate = campaignRate(slot.opens, slot.delivered, 'delivered');
|
|
100
|
-
if (!rate) continue;
|
|
101
|
-
const candidate = {
|
|
102
|
-
weekday: slot.weekday,
|
|
103
|
-
hour: slot.hour,
|
|
104
|
-
timeZone,
|
|
105
|
-
openRate: rate.value,
|
|
106
|
-
sends: slot.sends,
|
|
107
|
-
measured
|
|
108
|
-
};
|
|
109
|
-
const better = !best || candidate.openRate > best.openRate || candidate.openRate === best.openRate && (candidate.sends > best.sends || candidate.sends === best.sends && candidate.weekday * 24 + candidate.hour < best.weekday * 24 + best.hour);
|
|
110
|
-
if (better) best = candidate;
|
|
111
|
-
}
|
|
112
|
-
return best;
|
|
113
|
-
}
|
|
114
|
-
/** The slot as a person reads it: "Tuesdays around 9 AM (UTC)". */ export function campaignSendTimeLabel(suggestion) {
|
|
115
|
-
const hour12 = suggestion.hour % 12 === 0 ? 12 : suggestion.hour % 12;
|
|
116
|
-
const meridiem = suggestion.hour < 12 ? 'AM' : 'PM';
|
|
117
|
-
return `${WEEKDAY_NAMES[suggestion.weekday]}s around ${hour12} ${meridiem} (${suggestion.timeZone})`;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
//# sourceMappingURL=campaign-send-time.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/campaign-send-time.ts"],"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\nimport { campaignRate } from './campaign-report'\n\n/**\n * WHEN A LIST READS ITS MAIL: a suggested send time, taken from the sends that\n * already went to it.\n *\n * A rule and not a guess. Each past send is put in the slot it went out in —\n * its weekday and hour, in one named time zone — and the slot whose sends were\n * opened by the largest share of the people they reached is the suggestion.\n * The share is POOLED across a slot's sends (every unique open over every\n * delivered message) rather than averaged, so one small send that happened to\n * be opened by all nine of its recipients does not outrank a slot that reached\n * thousands.\n *\n * Only sends that can be measured are counted: a send with no delivered count\n * large enough to say anything is left out, and so is one whose opens exceed\n * its deliveries, which is a record that disagrees with itself. With fewer\n * measured sends than the minimum there is no suggestion at all — \"not enough\n * history\" is an answer a composer can say, and a slot picked from one send is\n * not.\n *\n * Pure, with the reads left to the caller that holds the credentials.\n */\n\n/** One past send to the list, as the suggestion needs it. */\nexport interface CampaignSendTimeSample {\n /** When the send went out. */\n sentAtMs: number\n /** Messages the provider delivered. */\n delivered: number\n /** Distinct recipients who opened it. */\n uniqueOpens: number\n}\n\nexport interface CampaignSendTimeSuggestion {\n /** 0 for Sunday through 6 for Saturday, in `timeZone`. */\n weekday: number\n /** 0 to 23, in `timeZone`. */\n hour: number\n /** The IANA zone the slot is stated in. */\n timeZone: string\n /** The pooled unique-open rate of the sends in the slot, 0 to 1. */\n openRate: number\n /** Measured sends that went out in the slot. */\n sends: number\n /** Measured sends the slot was chosen among. */\n measured: number\n}\n\n/** The fewest measured sends a suggestion is taken from. */\nexport const CAMPAIGN_SEND_TIME_MIN_SENDS = 3\n\n/** The fewest delivered messages that make a send measurable. */\nexport const CAMPAIGN_SEND_TIME_MIN_DELIVERED = 20\n\nconst WEEKDAYS: Record<string, number> = {\n Sun: 0,\n Mon: 1,\n Tue: 2,\n Wed: 3,\n Thu: 4,\n Fri: 5,\n Sat: 6,\n}\n\nconst WEEKDAY_NAMES = [\n 'Sunday',\n 'Monday',\n 'Tuesday',\n 'Wednesday',\n 'Thursday',\n 'Friday',\n 'Saturday',\n]\n\nfunction slotFormatter(timeZone: string): Intl.DateTimeFormat | null {\n try {\n return new Intl.DateTimeFormat('en-US', {\n timeZone,\n weekday: 'short',\n hour: 'numeric',\n hourCycle: 'h23',\n })\n } catch {\n return null\n }\n}\n\n/** The weekday and hour an instant falls in, in a zone. */\nfunction slotOf(\n formatter: Intl.DateTimeFormat,\n atMs: number,\n): { weekday: number; hour: number } | null {\n let weekday: number | undefined\n let hour: number | undefined\n for (const part of formatter.formatToParts(new Date(atMs))) {\n if (part.type === 'weekday') weekday = WEEKDAYS[part.value]\n if (part.type === 'hour') hour = Number(part.value) % 24\n }\n return weekday === undefined || hour === undefined || !Number.isFinite(hour)\n ? null\n : { weekday, hour }\n}\n\n/**\n * The slot the list's past sends were opened most in, or `null` when too few\n * sends can be measured. An unknown time zone is read as UTC, and the answer\n * says so.\n */\nexport function suggestCampaignSendTime(\n samples: ReadonlyArray<CampaignSendTimeSample>,\n options: { timeZone?: string } = {},\n): CampaignSendTimeSuggestion | null {\n const requested = options.timeZone?.trim() || 'UTC'\n const formatter = slotFormatter(requested) ?? slotFormatter('UTC')\n const timeZone = slotFormatter(requested) ? requested : 'UTC'\n if (!formatter) return null\n const slots = new Map<string, { weekday: number; hour: number; delivered: number; opens: number; sends: number }>()\n let measured = 0\n for (const sample of samples) {\n const delivered = Number(sample.delivered)\n const opens = Number(sample.uniqueOpens)\n if (!Number.isFinite(sample.sentAtMs) || sample.sentAtMs <= 0) continue\n if (!Number.isFinite(delivered) || delivered < CAMPAIGN_SEND_TIME_MIN_DELIVERED) continue\n if (!Number.isFinite(opens) || opens < 0 || opens > delivered) continue\n const slot = slotOf(formatter, sample.sentAtMs)\n if (!slot) continue\n measured += 1\n const key = `${slot.weekday}:${slot.hour}`\n const held = slots.get(key) ?? { ...slot, delivered: 0, opens: 0, sends: 0 }\n held.delivered += delivered\n held.opens += opens\n held.sends += 1\n slots.set(key, held)\n }\n if (measured < CAMPAIGN_SEND_TIME_MIN_SENDS) return null\n let best: CampaignSendTimeSuggestion | null = null\n for (const slot of slots.values()) {\n const rate = campaignRate(slot.opens, slot.delivered, 'delivered')\n if (!rate) continue\n const candidate: CampaignSendTimeSuggestion = {\n weekday: slot.weekday,\n hour: slot.hour,\n timeZone,\n openRate: rate.value,\n sends: slot.sends,\n measured,\n }\n const better =\n !best ||\n candidate.openRate > best.openRate ||\n (candidate.openRate === best.openRate &&\n (candidate.sends > best.sends ||\n (candidate.sends === best.sends &&\n candidate.weekday * 24 + candidate.hour < best.weekday * 24 + best.hour)))\n if (better) best = candidate\n }\n return best\n}\n\n/** The slot as a person reads it: \"Tuesdays around 9 AM (UTC)\". */\nexport function campaignSendTimeLabel(\n suggestion: Pick<CampaignSendTimeSuggestion, 'weekday' | 'hour' | 'timeZone'>,\n): string {\n const hour12 = suggestion.hour % 12 === 0 ? 12 : suggestion.hour % 12\n const meridiem = suggestion.hour < 12 ? 'AM' : 'PM'\n return `${WEEKDAY_NAMES[suggestion.weekday]}s around ${hour12} ${meridiem} (${suggestion.timeZone})`\n}\n"],"names":["campaignRate","CAMPAIGN_SEND_TIME_MIN_SENDS","CAMPAIGN_SEND_TIME_MIN_DELIVERED","WEEKDAYS","Sun","Mon","Tue","Wed","Thu","Fri","Sat","WEEKDAY_NAMES","slotFormatter","timeZone","Intl","DateTimeFormat","weekday","hour","hourCycle","slotOf","formatter","atMs","part","formatToParts","Date","type","value","Number","undefined","isFinite","suggestCampaignSendTime","samples","options","requested","trim","slots","Map","measured","sample","delivered","opens","uniqueOpens","sentAtMs","slot","key","held","get","sends","set","best","values","rate","candidate","openRate","better","campaignSendTimeLabel","suggestion","hour12","meridiem"],"mappings":"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"}
|