@antelopejs/dms-mailing 0.6.1 → 0.7.1

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 (236) hide show
  1. package/README.md +8 -7
  2. package/dist/constants.d.ts +1 -2
  3. package/dist/constants.js +2 -3
  4. package/dist/constants.js.map +1 -1
  5. package/dist/data/templates-table.d.ts +4 -1
  6. package/dist/data/templates-table.js +55 -20
  7. package/dist/data/templates-table.js.map +1 -1
  8. package/dist/data-types/categories-type.d.ts +5 -5
  9. package/dist/db/models/index.d.ts +0 -1
  10. package/dist/db/models/index.js +0 -1
  11. package/dist/db/models/index.js.map +1 -1
  12. package/dist/db/models/send-events.model.js +2 -3
  13. package/dist/db/models/send-events.model.js.map +1 -1
  14. package/dist/db/models/sends.model.js +2 -3
  15. package/dist/db/models/sends.model.js.map +1 -1
  16. package/dist/db/models/templates.model.d.ts +10 -1
  17. package/dist/db/models/templates.model.js +29 -10
  18. package/dist/db/models/templates.model.js.map +1 -1
  19. package/dist/db/tables/index.d.ts +0 -1
  20. package/dist/db/tables/index.js +0 -1
  21. package/dist/db/tables/index.js.map +1 -1
  22. package/dist/db/tables/settings.table.d.ts +0 -1
  23. package/dist/db/tables/settings.table.js +0 -4
  24. package/dist/db/tables/settings.table.js.map +1 -1
  25. package/dist/db/tables/templates.table.d.ts +7 -4
  26. package/dist/db/tables/templates.table.js +4 -14
  27. package/dist/db/tables/templates.table.js.map +1 -1
  28. package/dist/engine/formats.d.ts +13 -0
  29. package/dist/engine/formats.js +85 -0
  30. package/dist/engine/formats.js.map +1 -0
  31. package/dist/engine/index.d.ts +1 -0
  32. package/dist/engine/index.js +1 -0
  33. package/dist/engine/index.js.map +1 -1
  34. package/dist/engine/resolve.d.ts +2 -1
  35. package/dist/engine/resolve.js +14 -7
  36. package/dist/engine/resolve.js.map +1 -1
  37. package/dist/engine/tokens.d.ts +2 -1
  38. package/dist/engine/tokens.js +3 -5
  39. package/dist/engine/tokens.js.map +1 -1
  40. package/dist/frontend-module.d.ts +2 -0
  41. package/dist/frontend-module.js +24 -0
  42. package/dist/frontend-module.js.map +1 -0
  43. package/dist/hooks/index.d.ts +0 -1
  44. package/dist/hooks/index.js +0 -1
  45. package/dist/hooks/index.js.map +1 -1
  46. package/dist/hooks/tenant-export.js +4 -1
  47. package/dist/hooks/tenant-export.js.map +1 -1
  48. package/dist/implementations/dms-mailing/index.d.ts +3 -3
  49. package/dist/implementations/dms-mailing/index.js +8 -7
  50. package/dist/implementations/dms-mailing/index.js.map +1 -1
  51. package/dist/index.js +2 -20
  52. package/dist/index.js.map +1 -1
  53. package/dist/pages/overview.d.ts +1 -1
  54. package/dist/pages/overview.js +11 -13
  55. package/dist/pages/overview.js.map +1 -1
  56. package/dist/pages/sends.d.ts +1 -1
  57. package/dist/pages/sends.js +16 -8
  58. package/dist/pages/sends.js.map +1 -1
  59. package/dist/pages/settings/form.js +8 -0
  60. package/dist/pages/settings/form.js.map +1 -1
  61. package/dist/pages/templates.js +3 -8
  62. package/dist/pages/templates.js.map +1 -1
  63. package/dist/pages/toolbar.d.ts +12 -0
  64. package/dist/pages/toolbar.js +41 -0
  65. package/dist/pages/toolbar.js.map +1 -0
  66. package/dist/realtime.d.ts +2 -2
  67. package/dist/realtime.js +8 -8
  68. package/dist/realtime.js.map +1 -1
  69. package/dist/routes/metrics.d.ts +9 -2
  70. package/dist/routes/metrics.js +90 -35
  71. package/dist/routes/metrics.js.map +1 -1
  72. package/dist/routes/period.d.ts +1 -0
  73. package/dist/routes/period.js +19 -0
  74. package/dist/routes/period.js.map +1 -1
  75. package/dist/routes/sends.js +6 -3
  76. package/dist/routes/sends.js.map +1 -1
  77. package/dist/routes/settings.js +6 -7
  78. package/dist/routes/settings.js.map +1 -1
  79. package/dist/routes/templates.d.ts +8 -4
  80. package/dist/routes/templates.js +41 -20
  81. package/dist/routes/templates.js.map +1 -1
  82. package/dist/services/categories.d.ts +2 -1
  83. package/dist/services/categories.js +9 -0
  84. package/dist/services/categories.js.map +1 -1
  85. package/dist/services/declarations.js +42 -184
  86. package/dist/services/declarations.js.map +1 -1
  87. package/dist/services/events.js +1 -1
  88. package/dist/services/events.js.map +1 -1
  89. package/dist/services/localized-text.d.ts +7 -0
  90. package/dist/services/localized-text.js +28 -0
  91. package/dist/services/localized-text.js.map +1 -0
  92. package/dist/services/send-import.js +0 -2
  93. package/dist/services/send-import.js.map +1 -1
  94. package/dist/services/send-stat-items.js +34 -12
  95. package/dist/services/send-stat-items.js.map +1 -1
  96. package/dist/services/send-stats.d.ts +7 -0
  97. package/dist/services/send-stats.js +17 -5
  98. package/dist/services/send-stats.js.map +1 -1
  99. package/dist/services/send.d.ts +2 -0
  100. package/dist/services/send.js +11 -12
  101. package/dist/services/send.js.map +1 -1
  102. package/dist/services/sender-declaration.d.ts +2 -0
  103. package/dist/services/sender-declaration.js +18 -0
  104. package/dist/services/sender-declaration.js.map +1 -0
  105. package/dist/services/settings.d.ts +1 -1
  106. package/dist/services/settings.js +1 -5
  107. package/dist/services/settings.js.map +1 -1
  108. package/dist/services/starters.d.ts +2 -2
  109. package/dist/services/starters.js +1 -10
  110. package/dist/services/starters.js.map +1 -1
  111. package/dist/services/templates.d.ts +13 -7
  112. package/dist/services/templates.js +72 -33
  113. package/dist/services/templates.js.map +1 -1
  114. package/dist/services/webhook-status.d.ts +2 -1
  115. package/dist/services/webhook-status.js +6 -5
  116. package/dist/services/webhook-status.js.map +1 -1
  117. package/dist/types/settings.d.ts +8 -0
  118. package/dist/types/settings.js +33 -6
  119. package/dist/types/settings.js.map +1 -1
  120. package/dist/validation/declarations.schema.d.ts +25 -7
  121. package/dist/validation/declarations.schema.js +16 -3
  122. package/dist/validation/declarations.schema.js.map +1 -1
  123. package/dist/validation/settings.schema.d.ts +41 -41
  124. package/dist/validation/settings.schema.js +30 -6
  125. package/dist/validation/settings.schema.js.map +1 -1
  126. package/dist/validation/templates.schema.d.ts +13 -0
  127. package/dist/validation/templates.schema.js +6 -1
  128. package/dist/validation/templates.schema.js.map +1 -1
  129. package/frontend-vue/app/components/CategoriesInput.vue +15 -13
  130. package/frontend-vue/app/components/FunnelCard.vue +46 -3
  131. package/frontend-vue/app/components/NewTemplateModal.vue +4 -2
  132. package/frontend-vue/app/components/OverviewGate.vue +0 -8
  133. package/frontend-vue/app/components/ProviderPill.vue +50 -0
  134. package/frontend-vue/app/components/SendDrawer.vue +206 -183
  135. package/frontend-vue/app/components/StarterGallery.vue +1 -1
  136. package/frontend-vue/app/components/TemplateCard.vue +4 -1
  137. package/frontend-vue/app/components/TemplateDrawer.vue +3 -5
  138. package/frontend-vue/app/components/TemplateGallery.vue +57 -17
  139. package/frontend-vue/app/components/TemplatePreviewFrame.vue +41 -11
  140. package/frontend-vue/app/components/editor/EditorTemplatePane.vue +1 -3
  141. package/frontend-vue/app/components/editor/EditorTitle.vue +1 -7
  142. package/frontend-vue/app/composables/useLocalizedDefaults.ts +5 -7
  143. package/frontend-vue/app/composables/useMailingApi.ts +3 -3
  144. package/frontend-vue/app/composables/useMailingReads.ts +2 -2
  145. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -7
  146. package/frontend-vue/app/types/mailing.ts +18 -0
  147. package/frontend-vue/app/utils/categories.ts +58 -49
  148. package/frontend-vue/app/utils/funnel.ts +25 -0
  149. package/frontend-vue/app/utils/period.ts +0 -7
  150. package/frontend-vue/app/utils/starters.ts +3 -1
  151. package/frontend-vue/app/utils/tokens.ts +6 -1
  152. package/frontend-vue/i18n/locales/mailing-en-GB.json +19 -28
  153. package/frontend-vue/i18n/locales/mailing-fr-FR.json +19 -28
  154. package/frontend-vue/package.json +1 -1
  155. package/frontend-vue/tests/categories-input.test.ts +4 -10
  156. package/frontend-vue/tests/categories.test.ts +55 -17
  157. package/frontend-vue/tests/conditions.test.ts +1 -4
  158. package/frontend-vue/tests/funnel-card.test.ts +27 -0
  159. package/frontend-vue/tests/funnel.test.ts +25 -0
  160. package/frontend-vue/tests/localized-defaults.test.ts +0 -29
  161. package/frontend-vue/tests/new-template-modal.test.ts +1 -1
  162. package/frontend-vue/tests/overview-gate.test.ts +1 -24
  163. package/frontend-vue/tests/page-reads.test.ts +9 -25
  164. package/frontend-vue/tests/period.test.ts +0 -12
  165. package/frontend-vue/tests/replay-toast.test.ts +2 -1
  166. package/frontend-vue/tests/send-drawer.test.ts +123 -0
  167. package/frontend-vue/tests/template-drawer.test.ts +1 -1
  168. package/frontend-vue/tests/template-gallery.test.ts +90 -3
  169. package/frontend-vue/tests/template-preview-frame.test.ts +92 -0
  170. package/package.json +8 -7
  171. package/skills/dms-mailing/SKILL.md +25 -18
  172. package/src/constants.ts +2 -2
  173. package/src/data/sends-table.ts +1 -1
  174. package/src/data/templates-table.ts +125 -33
  175. package/src/db/models/index.ts +0 -1
  176. package/src/db/models/send-events.model.ts +2 -2
  177. package/src/db/models/sends.model.ts +7 -8
  178. package/src/db/models/templates.model.ts +64 -16
  179. package/src/db/tables/index.ts +0 -1
  180. package/src/db/tables/settings.table.ts +0 -8
  181. package/src/db/tables/templates.table.ts +12 -17
  182. package/src/engine/formats.ts +124 -0
  183. package/src/engine/index.ts +1 -0
  184. package/src/engine/resolve.ts +18 -7
  185. package/src/engine/tokens.ts +7 -5
  186. package/src/frontend-module.ts +36 -0
  187. package/src/hooks/index.ts +0 -1
  188. package/src/hooks/tenant-export.ts +8 -1
  189. package/src/implementations/dms-mailing/index.ts +5 -16
  190. package/src/index.ts +3 -29
  191. package/src/pages/overview.ts +20 -16
  192. package/src/pages/sends.ts +27 -11
  193. package/src/pages/settings/form.ts +16 -0
  194. package/src/pages/templates.ts +4 -8
  195. package/src/pages/toolbar.ts +77 -0
  196. package/src/realtime.ts +17 -8
  197. package/src/routes/metrics.ts +158 -45
  198. package/src/routes/period.ts +32 -0
  199. package/src/routes/sends.ts +13 -4
  200. package/src/routes/settings.ts +11 -8
  201. package/src/routes/templates.ts +81 -37
  202. package/src/services/categories.ts +14 -1
  203. package/src/services/declarations.ts +64 -317
  204. package/src/services/events.ts +1 -1
  205. package/src/services/localized-text.ts +53 -0
  206. package/src/services/send-import.ts +2 -2
  207. package/src/services/send-stat-items.ts +60 -13
  208. package/src/services/send-stats.ts +37 -5
  209. package/src/services/send.ts +19 -20
  210. package/src/services/sender-declaration.ts +27 -0
  211. package/src/services/settings.ts +3 -10
  212. package/src/services/starters.ts +3 -15
  213. package/src/services/templates.ts +109 -63
  214. package/src/services/webhook-status.ts +3 -3
  215. package/src/types/settings.ts +55 -5
  216. package/src/validation/declarations.schema.ts +29 -3
  217. package/src/validation/settings.schema.ts +56 -14
  218. package/src/validation/templates.schema.ts +10 -0
  219. package/dist/db/models/template-slugs.model.d.ts +0 -27
  220. package/dist/db/models/template-slugs.model.js +0 -43
  221. package/dist/db/models/template-slugs.model.js.map +0 -1
  222. package/dist/db/tables/template-slugs.table.d.ts +0 -7
  223. package/dist/db/tables/template-slugs.table.js +0 -35
  224. package/dist/db/tables/template-slugs.table.js.map +0 -1
  225. package/dist/hooks/database-ready.d.ts +0 -7
  226. package/dist/hooks/database-ready.js +0 -26
  227. package/dist/hooks/database-ready.js.map +0 -1
  228. package/dist/services/fingerprint.d.ts +0 -1
  229. package/dist/services/fingerprint.js +0 -20
  230. package/dist/services/fingerprint.js.map +0 -1
  231. package/frontend-vue/app/components/PageHeader.vue +0 -94
  232. package/frontend-vue/app/composables/useOverviewSetup.ts +0 -21
  233. package/src/db/models/template-slugs.model.ts +0 -61
  234. package/src/db/tables/template-slugs.table.ts +0 -29
  235. package/src/hooks/database-ready.ts +0 -47
  236. package/src/services/fingerprint.ts +0 -22
@@ -23,6 +23,7 @@ import {
23
23
  import { OverviewPageController } from "../pages/overview";
24
24
  import { readCapabilities } from "../services/provider";
25
25
  import { trackingOf } from "../services/send-stats";
26
+ import { localeOfRequest } from "../services/localized-text";
26
27
  import { findSettings } from "../services/settings";
27
28
  import { attentionFeedItem, buildAttentionItems } from "../services/attention";
28
29
  import {
@@ -44,7 +45,12 @@ import {
44
45
  type TotalsExtractor,
45
46
  } from "../services/metrics";
46
47
  import { SEND_AUDIENCE_QUERY_KEY, type SendAudience } from "../types";
47
- import { comparisonRange, currentRange, isComparisonWanted } from "./period";
48
+ import {
49
+ comparisonLabel,
50
+ comparisonRange,
51
+ currentRange,
52
+ isComparisonWanted,
53
+ } from "./period";
48
54
 
49
55
  const UNKNOWN_METRIC = "$dms_mailing.errors.unknown_metric";
50
56
  const TEMPLATES_PATH = "/modules/mailing/templates";
@@ -140,12 +146,18 @@ export interface FunnelStepResponse {
140
146
  rate: number | null;
141
147
  /** `false` when nothing reports the events the step counts. */
142
148
  isMeasured: boolean;
149
+ /** The step's count over the comparison window, when both measure it. */
150
+ previousCount?: number;
151
+ /** The change of the count, in percent; absent from an empty comparison. */
152
+ delta?: number;
143
153
  }
144
154
 
145
155
  /** The sends of the window the figures are compared with. */
146
156
  export interface ComparedWindow {
147
157
  range: DateRange;
148
158
  sends: SendSummary[];
159
+ /** What the comparison series is called: the previous period, year… */
160
+ label: string;
149
161
  }
150
162
 
151
163
  /** What the Overview's first-run checklist reads. */
@@ -173,6 +185,13 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
173
185
  return getRequestTenantId(this.ctx);
174
186
  }
175
187
 
188
+ /** The tenant's templates, their names in the reader's language. */
189
+ private async namedTemplates(): Promise<MailingTemplate[]> {
190
+ const locale = localeOfRequest(this.ctx);
191
+ const templates = await this.templates.getAll();
192
+ return templates.map((template) => template.localize(locale, ["name"]));
193
+ }
194
+
176
195
  /**
177
196
  * Which sends this request describes. Declared by the calling page, never
178
197
  * inferred: the Overview asks for the business view, the Sends page for the
@@ -193,7 +212,11 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
193
212
  ): Promise<ComparedWindow | null> {
194
213
  if (!isComparisonWanted(this.ctx)) return null;
195
214
  const previousRange = comparisonRange(this.ctx, range);
196
- return { range: previousRange, sends: await this.load(previousRange) };
215
+ return {
216
+ range: previousRange,
217
+ sends: await this.load(previousRange),
218
+ label: comparisonLabel(this.ctx),
219
+ };
197
220
  }
198
221
 
199
222
  @Get("/kpi/:metric")
@@ -231,37 +254,58 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
231
254
  const metric =
232
255
  TOP_METRICS[this.ctx.url.searchParams.get("metric") ?? ""] ??
233
256
  DEFAULT_TOP_METRIC;
234
- const [sends, templates] = await Promise.all([
235
- this.load(currentRange(this.ctx)),
236
- this.templates.getAll(),
257
+ const range = currentRange(this.ctx);
258
+ const [sends, previous, templates] = await Promise.all([
259
+ this.load(range),
260
+ this.loadComparison(range),
261
+ this.namedTemplates(),
237
262
  ]);
238
- const items = [...groupBy(sends, (send) => send.templateSlug)].map(
239
- ([slug, rows]) =>
240
- toTemplateItem(slug, aggregate(rows), templates, metric),
263
+ const bySlug = (send: SendSummary) => send.templateSlug;
264
+ const before = previous ? totalsBy(previous.sends, bySlug) : null;
265
+ const items = [...groupBy(sends, bySlug)].map(([slug, rows]) =>
266
+ toTemplateItem(
267
+ slug,
268
+ aggregate(rows),
269
+ templates,
270
+ metric,
271
+ before?.get(slug),
272
+ ),
241
273
  );
242
274
  return { items: ranked(items, MAX_TOP_TEMPLATES) };
243
275
  }
244
276
 
245
277
  @Get("/domains")
246
278
  async domains() {
247
- const sends = await this.load(currentRange(this.ctx));
248
- const items = [
249
- ...groupBy(sends, (send) => domainOf(send.recipientEmail)),
250
- ].map(([domain, rows]) => toDomainItem(domain, aggregate(rows)));
279
+ const range = currentRange(this.ctx);
280
+ const [sends, previous] = await Promise.all([
281
+ this.load(range),
282
+ this.loadComparison(range),
283
+ ]);
284
+ const byDomain = (send: SendSummary) => domainOf(send.recipientEmail);
285
+ const before = previous ? totalsBy(previous.sends, byDomain) : null;
286
+ const items = [...groupBy(sends, byDomain)].map(([domain, rows]) =>
287
+ toDomainItem(domain, aggregate(rows), before?.get(domain)),
288
+ );
251
289
  return { items: ranked(items, MAX_TOP_DOMAINS) };
252
290
  }
253
291
 
254
292
  @Get("/funnel")
255
293
  async funnel() {
256
294
  const kind = readFunnelKind(this.ctx.url.searchParams.get("kind"));
257
- const [sends, templates, tracking] = await Promise.all([
258
- this.load(currentRange(this.ctx)),
259
- this.templates.getAll(),
295
+ const range = currentRange(this.ctx);
296
+ const [sends, previous, templates, tracking] = await Promise.all([
297
+ this.load(range),
298
+ this.loadComparison(range),
299
+ this.namedTemplates(),
260
300
  readTracking(),
261
301
  ]);
262
- const kept = filterByKind(sends, templates, kind);
302
+ const funnelOf = (rows: SendSummary[]): FunnelStepResponse[] => {
303
+ const kept = filterByKind(rows, templates, kind);
304
+ return funnelSteps(aggregate(kept), isDeliveryUntracked(kept, tracking));
305
+ };
306
+ const steps = funnelOf(sends);
263
307
  return {
264
- steps: funnelSteps(aggregate(kept), isDeliveryUntracked(kept, tracking)),
308
+ steps: previous ? comparedSteps(steps, funnelOf(previous.sends)) : steps,
265
309
  };
266
310
  }
267
311
 
@@ -269,7 +313,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
269
313
  async attention() {
270
314
  const range = currentRange(this.ctx);
271
315
  const [templates, rows, tracking, settings] = await Promise.all([
272
- this.templates.getAll(),
316
+ this.namedTemplates(),
273
317
  this.sends.listBetween(range.from, range.to),
274
318
  readTracking(),
275
319
  findSettings(this.tenantId),
@@ -292,7 +336,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
292
336
  async setup(): Promise<SetupFactsResponse> {
293
337
  const [capabilities, templates, settings, testSends] = await Promise.all([
294
338
  readCapabilities(),
295
- this.templates.getAll(),
339
+ this.namedTemplates(),
296
340
  findSettings(this.tenantId),
297
341
  this.sends.table
298
342
  .filter((row) => row.key("isTest").eq(true))
@@ -366,21 +410,22 @@ export function volumeChart(
366
410
  const sent = current.filter(leftForProvider);
367
411
  const previousSent = previous?.sends.filter(leftForProvider) ?? null;
368
412
  const series = dailySeries(sent, range.from, range.to);
369
- const comparison =
413
+ const comparisonSeries =
370
414
  previous && previousSent
371
- ? dailySeries(previousSent, previous.range.from, previous.range.to)
372
- : null;
373
- return {
374
- ...comparedFigure(sent.length, previousSent ? previousSent.length : null),
375
- series: [{ name: "$dms_mailing.metrics.sends", data: series }],
376
- comparisonSeries: comparison
377
415
  ? [
378
416
  {
379
- name: "$dms_mailing.metrics.previous",
380
- data: alignSeries(comparison, series),
417
+ name: previous.label,
418
+ data: alignSeries(
419
+ dailySeries(previousSent, previous.range.from, previous.range.to),
420
+ series,
421
+ ),
381
422
  },
382
423
  ]
383
- : [],
424
+ : [];
425
+ return {
426
+ ...comparedFigure(sent.length, previousSent ? previousSent.length : null),
427
+ series: [{ name: "$dms_mailing.metrics.sends", data: series }],
428
+ comparisonSeries,
384
429
  };
385
430
  }
386
431
 
@@ -397,6 +442,28 @@ export function funnelSteps(
397
442
  }));
398
443
  }
399
444
 
445
+ /**
446
+ * The steps with their count over the comparison window, step by step. A step
447
+ * either window does not measure states no comparison: a zero there would
448
+ * read as a fall that never happened.
449
+ */
450
+ export function comparedSteps(
451
+ current: FunnelStepResponse[],
452
+ previous: FunnelStepResponse[],
453
+ ): FunnelStepResponse[] {
454
+ return current.map((step) => {
455
+ const before = previous.find((candidate) => candidate.id === step.id);
456
+ if (!step.isMeasured || !before?.isMeasured) return step;
457
+ const compared: FunnelStepResponse = {
458
+ ...step,
459
+ previousCount: before.count,
460
+ };
461
+ const delta = deltaPercent(step.count, before.count);
462
+ if (delta !== null) compared.delta = delta;
463
+ return compared;
464
+ });
465
+ }
466
+
400
467
  /** Which sends the funnel counts: all, or one side of the marketing split. */
401
468
  export type FunnelKind = "all" | "marketing" | "transactional";
402
469
 
@@ -441,6 +508,35 @@ async function readTracking(): Promise<ProviderTracking | null> {
441
508
  return trackingOf(await readCapabilities());
442
509
  }
443
510
 
511
+ /** The totals of each group of sends, by the key `keyOf` gives a send. */
512
+ export function totalsBy(
513
+ sends: SendSummary[],
514
+ keyOf: (send: SendSummary) => string,
515
+ ): Map<string, Totals> {
516
+ return new Map(
517
+ [...groupBy(sends, keyOf)].map(([key, rows]) => [key, aggregate(rows)]),
518
+ );
519
+ }
520
+
521
+ /**
522
+ * A ranked row's change against the comparison window: `null` when the row
523
+ * had too few deliveries there to be ranked, its figure being then a guess.
524
+ */
525
+ export function rowDelta(
526
+ value: number,
527
+ previous: Totals | undefined,
528
+ metric: TopMetric,
529
+ ): number | null {
530
+ if (!previous || previous.delivered < metric.minDeliveries) return null;
531
+ return deltaPercent(value, metric.extract(previous));
532
+ }
533
+
534
+ /** The row with its change; with none to state, the row carries no delta. */
535
+ function withDelta(item: TopListItem, delta: number | null): TopListItem {
536
+ if (delta !== null) item.delta = delta;
537
+ return item;
538
+ }
539
+
444
540
  /** The rows that can be ranked, best first, at most `limit` of them. */
445
541
  export function ranked(
446
542
  items: Array<TopListItem | null>,
@@ -457,37 +553,54 @@ const deliveredCount = (delivered: number): ComposedText => ({
457
553
  params: { count: { type: "count", value: delivered } },
458
554
  });
459
555
 
556
+ /** The domains are ranked on their open rate, as the templates by default. */
557
+ const DOMAIN_METRIC = DEFAULT_TOP_METRIC;
558
+
559
+ /**
560
+ * A domain's row. `previous`, its totals over the comparison window when the
561
+ * toolbar compares, gives the row its change.
562
+ */
460
563
  export function toDomainItem(
461
564
  domain: string,
462
565
  totals: Totals,
566
+ previous?: Totals,
463
567
  ): TopListItem | null {
464
- if (totals.openRate === null || totals.delivered < MIN_RANKED_DELIVERIES)
568
+ const value = DOMAIN_METRIC.extract(totals);
569
+ if (value === null || totals.delivered < DOMAIN_METRIC.minDeliveries)
465
570
  return null;
466
- return {
467
- id: domain,
468
- title: domain,
469
- description: deliveredCount(totals.delivered),
470
- value: totals.openRate,
471
- };
571
+ return withDelta(
572
+ {
573
+ id: domain,
574
+ title: domain,
575
+ description: deliveredCount(totals.delivered),
576
+ value,
577
+ },
578
+ rowDelta(value, previous, DOMAIN_METRIC),
579
+ );
472
580
  }
473
581
 
582
+ /** A template's row, its change read from `previous` as a domain's. */
474
583
  export function toTemplateItem(
475
584
  slug: string,
476
585
  totals: Totals,
477
586
  templates: MailingTemplate[],
478
587
  metric: TopMetric,
588
+ previous?: Totals,
479
589
  ): TopListItem | null {
480
590
  const value = metric.extract(totals);
481
591
  if (value === null || totals.delivered < metric.minDeliveries) return null;
482
592
  const template = templates.find((candidate) => candidate.slug === slug);
483
- return {
484
- id: slug,
485
- title: template?.name ?? slug,
486
- description: {
487
- key: TEMPLATE_VOLUME_KEY,
488
- params: { slug, count: { type: "count", value: totals.delivered } },
593
+ return withDelta(
594
+ {
595
+ id: slug,
596
+ title: template?.name ?? slug,
597
+ description: {
598
+ key: TEMPLATE_VOLUME_KEY,
599
+ params: { slug, count: { type: "count", value: totals.delivered } },
600
+ },
601
+ value,
602
+ to: template ? `${TEMPLATES_PATH}/${template._id}` : undefined,
489
603
  },
490
- value,
491
- to: template ? `${TEMPLATES_PATH}/${template._id}` : undefined,
492
- };
604
+ rowDelta(value, previous, metric),
605
+ );
493
606
  }
@@ -12,6 +12,17 @@ const DAY_MS = 86_400_000;
12
12
  const DEFAULT_WINDOW_DAYS = 30;
13
13
  /** The DMS period selector's `comparison` value when the user turned it off. */
14
14
  const NO_COMPARISON = "none";
15
+ const PREVIOUS_YEAR = "previous-year";
16
+
17
+ /**
18
+ * What the comparison series is called, by the selector's `comparison`. A
19
+ * custom window, or a caller naming none, is neither of the two.
20
+ */
21
+ const COMPARISON_SERIES_LABELS: Record<string, string> = {
22
+ "previous-period": "$dms_mailing.metrics.previous",
23
+ [PREVIOUS_YEAR]: "$dms_mailing.metrics.previous_year",
24
+ };
25
+ const COMPARED_SERIES_LABEL = "$dms_mailing.metrics.compared";
15
26
 
16
27
  function readQuery(ctx: RangeContext, key: string): string | undefined {
17
28
  return ctx.url.searchParams.get(key) ?? undefined;
@@ -53,6 +64,24 @@ export function isComparisonWanted(ctx: RangeContext): boolean {
53
64
  return readQuery(ctx, "comparison") !== NO_COMPARISON;
54
65
  }
55
66
 
67
+ /** The label of the comparison series, for the comparison the caller asks. */
68
+ export function comparisonLabel(ctx: RangeContext): string {
69
+ const comparison = readQuery(ctx, "comparison") ?? "previous-period";
70
+ return COMPARISON_SERIES_LABELS[comparison] ?? COMPARED_SERIES_LABEL;
71
+ }
72
+
73
+ function yearBefore(date: Date): Date {
74
+ const shifted = new Date(date.getTime());
75
+ shifted.setUTCFullYear(shifted.getUTCFullYear() - 1);
76
+ return shifted;
77
+ }
78
+
79
+ /**
80
+ * The window the current one is compared with. The DMS selector resolves it
81
+ * itself and sends `compareFrom` / `compareTo`, for the previous year as for
82
+ * the previous period; a caller sending only `comparison` gets the same
83
+ * window a year back, or the equal-length span just before.
84
+ */
56
85
  export function comparisonRange(
57
86
  ctx: RangeContext,
58
87
  current: DateRange,
@@ -60,6 +89,9 @@ export function comparisonRange(
60
89
  const from = parseDate(readQuery(ctx, "compareFrom"));
61
90
  const to = parseDate(readQuery(ctx, "compareTo"));
62
91
  if (from && to) return boundedWindow(from, to);
92
+ if (readQuery(ctx, "comparison") === PREVIOUS_YEAR) {
93
+ return { from: yearBefore(current.from), to: yearBefore(current.to) };
94
+ }
63
95
  const span = current.to.getTime() - current.from.getTime();
64
96
  return {
65
97
  from: new Date(current.from.getTime() - span),
@@ -35,9 +35,10 @@ import {
35
35
  SendModel,
36
36
  TemplateModel,
37
37
  } from "../db";
38
- import { resolveEmail } from "../engine";
38
+ import { resolveEmail, valueFormatOf } from "../engine";
39
39
  import type { InterpolationMode } from "../engine/tokens";
40
40
  import { SendsPageController } from "../pages/sends";
41
+ import { localeOfRequest } from "../services/localized-text";
41
42
  import { readCapabilities } from "../services/provider";
42
43
  import { buildProviderBanner } from "../services/provider-banner";
43
44
  import { renderEmailHtml } from "../services/render";
@@ -65,6 +66,7 @@ import {
65
66
  pickLocale,
66
67
  publishedVersionOf,
67
68
  requireTemplate,
69
+ snapshotOfVersion,
68
70
  } from "../services/templates";
69
71
  import type {
70
72
  SendStatus,
@@ -383,7 +385,9 @@ export class SendsController extends Controller(API_BASE_PATH) {
383
385
  return {
384
386
  send,
385
387
  events,
386
- template: template ? summarize(template) : null,
388
+ template: template
389
+ ? summarize(template.localize(localeOfRequest(this.ctx), ["name"]))
390
+ : null,
387
391
  suppression,
388
392
  };
389
393
  }
@@ -396,7 +400,7 @@ export class SendsController extends Controller(API_BASE_PATH) {
396
400
  async html(@Parameter("id", "param") id: string): Promise<SendHtmlResponse> {
397
401
  const send = await this.requireSend(id);
398
402
  const template = await requireTemplate(this.templates, send.templateId);
399
- const content = await contentOfVersion(
403
+ const { content, variables } = await snapshotOfVersion(
400
404
  this.tenantId,
401
405
  template,
402
406
  send.templateVersion,
@@ -405,7 +409,12 @@ export class SendsController extends Controller(API_BASE_PATH) {
405
409
  const locale = pickLocale(content, send.locale, settings.fallbackLocale);
406
410
  const localeContent = localeContentOf(content, locale);
407
411
  assert(localeContent, HTTP_UNPROCESSABLE, INVALID_CONTENT);
408
- const email = resolveEmail(localeContent, variablesOf(send), SEND_MODE);
412
+ const email = resolveEmail(
413
+ localeContent,
414
+ variablesOf(send),
415
+ SEND_MODE,
416
+ valueFormatOf(locale, variables),
417
+ );
409
418
  const version = send.templateVersion;
410
419
  return {
411
420
  html: await renderEmailHtml(email, locale),
@@ -71,7 +71,11 @@ const retentionDaysSchema = z.coerce
71
71
  .min(MIN_RETENTION_DAYS)
72
72
  .max(MAX_RETENTION_DAYS);
73
73
 
74
- /** The settings as the form reads them, with the values derived for display. */
74
+ /**
75
+ * The settings as the form reads them, with the values derived for display.
76
+ * The DMS form reads and writes localized values whole
77
+ * (`x-content-language: *`): category labels travel with every language.
78
+ */
75
79
  export interface MailingSettingsResponse extends MailingSettingsValues {
76
80
  /** Where the provider posts its events; read-only. */
77
81
  webhookUrl: string;
@@ -217,14 +221,13 @@ export class MailingSettingsController extends Controller(
217
221
  */
218
222
  @Put("")
219
223
  async update(@RawBody() raw: Buffer): Promise<SettingsWriteResponse> {
220
- const patch = this.readPatch(raw);
224
+ const { categories: entries, ...patch } = this.readPatch(raw);
221
225
  const current = await getSettings(this.tenantId);
222
226
  const merged = mergeSettingsPatch(current, patch);
223
- const entries = patch.categories ?? current.categories;
224
- const values = { ...merged, categories: keptCategories(entries) };
225
- const saved = await saveSettings(this.tenantId, values);
226
- const reassigned = patch.categories
227
- ? await this.reassignCategories(values.categories, categoryMoves(entries))
227
+ const categories = entries ? keptCategories(entries) : current.categories;
228
+ const saved = await saveSettings(this.tenantId, { ...merged, categories });
229
+ const reassigned = entries
230
+ ? await this.reassignCategories(categories, categoryMoves(entries))
228
231
  : 0;
229
232
  const [notice] = [...reassignedNotice(reassigned), ...senderNotice(saved)];
230
233
  return { ...saved, ...(notice && { notice }) };
@@ -324,7 +327,7 @@ export class MailingSettingsController extends Controller(
324
327
  const templates = await this.templates.getAll();
325
328
  const changes = reassignRemovedCategories(templates, categories, moves);
326
329
  for (const { templateId, category } of changes) {
327
- await this.templates.update(templateId, { category });
330
+ await this.templates.updateFields(templateId, { category });
328
331
  }
329
332
  return changes.length;
330
333
  }