@aglyn/aglyn 1.0.0-beta.262 → 1.0.0-beta.264

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.
@@ -14,29 +14,32 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import { type PluginFigureCell, type PluginFigureHeadline, type PluginFigureRow, type PluginFigureTable } from '../plugin-manager/plugin-figures';
17
+ import { type PluginFigureCell, type PluginFigureRow, type PluginFigureSummaryPart, type PluginFigureSummaryStat, type PluginFigureTable } from '../plugin-manager/plugin-figures';
18
18
  /**
19
- * THE WEEKLY SUMMARY (AGL-3867): the free Monday email that says how each of
20
- * a workspace's sites did last week, and how the workspace's own figures
21
- * moved.
19
+ * THE WEEKLY SUMMARY (AGL-3867): the free Monday emails that say how a
20
+ * workspace did the week before — one for each site that had something to
21
+ * report, and an overview of the workspace that adds the sites up.
22
22
  *
23
- * It spends nothing. Every figure comes from a `plugin-figures` reader that
24
- * declares a `weeklySummary`, and that reader's owner words its own lines, so
25
- * each plugin's counting rules decide what a figure is and nothing here names
26
- * one. AI weekly insights (AGL-2915) are the paid version that explains the
27
- * figures, and stay opt-in because each one spends the workspace's AI credits.
23
+ * They spend nothing. Every figure comes from a `plugin-figures` reader that
24
+ * declares a `weeklySummary`, and that reader's owner says what its table
25
+ * means as tiles and ranked rows, so each plugin's counting rules decide what
26
+ * a figure is and nothing here names one. AI weekly insights (AGL-2915) are
27
+ * the paid version that explains the figures, and stay opt-in because each
28
+ * one spends the workspace's AI credits.
28
29
  *
29
- * This module is the period, the shared wording helpers the readers use, and
30
- * the email's words; the reads and the sends are
31
- * `@aglyn/tenant-data-admin/server/weekly-summary`.
30
+ * This module is the period, the wording helpers the readers use, and both
31
+ * emails' words and layout; the reads and the sends are
32
+ * `@aglyn/tenant-data-admin/server/weekly-summary`. The layout is drawn as
33
+ * email-safe tables with inline styles, in colors the caller resolves from
34
+ * the email theme.
32
35
  */
33
36
  /** The key a person's switch is stored under in `users/{uid}.digestPrefs`; on until turned off. */
34
37
  export declare const WEEKLY_SUMMARY_DIGEST_KEY = "weeklySummary";
35
38
  /** The window every figure is read over. */
36
39
  export declare const WEEKLY_SUMMARY_DAYS = 7;
37
- /** Rows a ranked line names: the top three. */
38
- export declare const WEEKLY_SUMMARY_TOP_ROWS = 3;
39
- /** Counts the subject line leads with, at most. */
40
+ /** Rows a ranked list names. */
41
+ export declare const WEEKLY_SUMMARY_TOP_ROWS = 5;
42
+ /** Counts a subject line or the overview's tiles lead with, at most. */
40
43
  export declare const WEEKLY_SUMMARY_HEADLINE_COUNTS = 3;
41
44
  /**
42
45
  * The week a run reports on: the seven whole UTC days before the day it runs,
@@ -58,43 +61,113 @@ export declare function figureCount(value: PluginFigureCell | undefined): number
58
61
  export declare function figureRow(table: PluginFigureTable, key: string, label: string): PluginFigureRow | undefined;
59
62
  /** Whether a table carries a column, so a reader that changed shape is noticed rather than misread. */
60
63
  export declare function figureHasColumn(table: PluginFigureTable, key: string): boolean;
61
- /** "1,204 (+12%)": a cell and, when there is an earlier week to compare with, its change. */
62
- export declare function figureWithChange(table: PluginFigureTable, row: PluginFigureRow, key: string, changeKey: string): string;
63
- /** The named rows with the most of `key`, as "name 12 unit, name 4"; `null` when none has any. */
64
- export declare function figureRanked(table: PluginFigureTable, nameKey: string, key: string, unit?: string, rows?: readonly PluginFigureRow[]): string | null;
64
+ /** A cell as a tile, with its change when the table has one. */
65
+ export declare function figureStat(table: PluginFigureTable, row: PluginFigureRow, key: string, label: string, options?: {
66
+ change?: string;
67
+ total?: {
68
+ one: string;
69
+ many: string;
70
+ };
71
+ }): PluginFigureSummaryStat;
72
+ /** The named rows with the most of `key`, as ranked rows; `[]` when none has any. */
73
+ export declare function figureRanked(table: PluginFigureTable, nameKey: string, key: string, rows?: readonly PluginFigureRow[]): Array<{
74
+ label: string;
75
+ value: string;
76
+ }>;
65
77
  /** "1 order", "18 orders". */
66
78
  export declare function figurePlural(count: number, one: string, many: string): string;
67
- /** What one reader said this week: its lines and the count it adds to the subject. */
68
- export interface WeeklySummaryEntry {
69
- lines: string[];
70
- headline: PluginFigureHeadline | null;
71
- }
72
- /** One site's part of the email. */
79
+ /** One site's figures for the week. */
73
80
  export interface WeeklySummarySite {
74
81
  name: string;
75
82
  /** The site's dashboard in the console, absolute; `null` with no origin to build it from. */
76
83
  url: string | null;
77
- entries: readonly WeeklySummaryEntry[];
84
+ /**
85
+ * Where the site itself is, so a reader knows which site this is: its
86
+ * custom domain when it serves on one, else its platform address. `null`
87
+ * for a site with no address yet.
88
+ */
89
+ address?: {
90
+ label: string;
91
+ url: string;
92
+ } | null;
93
+ parts: readonly PluginFigureSummaryPart[];
94
+ }
95
+ /** The colors an email is drawn in, resolved from its theme by the caller. */
96
+ export interface WeeklySummaryColors {
97
+ text: string;
98
+ muted: string;
99
+ border: string;
100
+ /** A tile's ground. */
101
+ surface: string;
102
+ /** The accent rule beside a callout. */
103
+ accent: string;
104
+ /** Links: the email theme's own link color, the one every other link in the email wears. */
105
+ link: string;
106
+ /** A rise and a fall. */
107
+ up: string;
108
+ down: string;
109
+ }
110
+ /** A site with anything to say. */
111
+ export declare function weeklySummaryHasNews(site: Pick<WeeklySummarySite, 'parts'>): boolean;
112
+ /** The counts the readers offered for adding up, summed by noun, in the order they were first given. */
113
+ export declare function weeklySummaryTotals(sites: ReadonlyArray<Pick<WeeklySummarySite, 'parts'>>): Array<{
114
+ count: number;
115
+ one: string;
116
+ many: string;
117
+ }>;
118
+ /** "+12.5%", "−27.9%", "no change"; `null` with nothing to compare with. */
119
+ export declare function weeklySummaryChange(change: number | null | undefined): {
120
+ text: string;
121
+ sign: 1 | -1 | 0;
122
+ } | null;
123
+ /**
124
+ * How many tiles share each row: one row of up to three, two rows of two for
125
+ * four, then rows of three — so a row is never left with an empty slot
126
+ * beside two tiles.
127
+ */
128
+ export declare function weeklySummaryTileRows(count: number): number[];
129
+ /** What a send fills a weekly summary email's tokens with. */
130
+ export interface WeeklySummaryEmail {
131
+ subject: string;
132
+ /** The heading: "Aglyn last week". */
133
+ title: string;
134
+ /** "Oct 5 – Oct 11, compared with the week before". */
135
+ period: string;
136
+ /** The figures, drawn. */
137
+ html: string;
138
+ /** The whole email as plain text, closing line included. */
139
+ text: string;
140
+ buttonLabel: string;
141
+ /** Where the button goes; `''` when there is no console origin to link to. */
142
+ url: string;
78
143
  }
79
- /** The subject's counts, summed across sites by their noun, in the order the readers first gave them. */
80
- export declare function weeklySummaryHeadline(sites: readonly WeeklySummarySite[]): string;
81
- export declare function composeWeeklySummarySubject(workspaceName: string, sites: readonly WeeklySummarySite[]): string;
82
- export interface WeeklySummaryEmailInput {
144
+ interface CommonInput {
83
145
  workspaceName: string;
84
146
  productName: string;
85
147
  periodLabel: string;
86
- sites: readonly WeeklySummarySite[];
87
- /** The workspace's own figures, such as its deals. */
88
- workspace: readonly WeeklySummaryEntry[];
148
+ colors: WeeklySummaryColors;
89
149
  /** Where AI weekly insights are turned on, when the plan includes them and this person has not; else `null`. */
90
150
  aiInsightsUrl: string | null;
91
151
  settingsUrl: string;
92
- /** Drops the closing "why" line, which the designed email's footer carries. */
93
- omitClosing?: boolean;
94
152
  }
95
153
  /**
96
- * The email's words, or `null` when there is nothing to report at all — a
97
- * week with no figures is not worth an email, and sending one anyway is how a
98
- * summary trains people to ignore it. A site with nothing to say is left out.
154
+ * One site's email, or `null` for a site with nothing to report. `workspace`
155
+ * carries the workspace's own parts when this site's email stands in for the
156
+ * overview, in a workspace with one active site.
99
157
  */
100
- export declare function composeWeeklySummaryEmailText(input: WeeklySummaryEmailInput): string | null;
158
+ export declare function composeWeeklySummarySiteEmail(input: CommonInput & {
159
+ site: WeeklySummarySite;
160
+ workspace?: readonly PluginFigureSummaryPart[];
161
+ }): WeeklySummaryEmail | null;
162
+ /**
163
+ * The workspace overview: the week's totals across its sites, a line for each
164
+ * site, and the workspace's own figures, or `null` when nothing anywhere has
165
+ * anything to report.
166
+ */
167
+ export declare function composeWeeklySummaryOverviewEmail(input: CommonInput & {
168
+ sites: readonly WeeklySummarySite[];
169
+ workspace: readonly PluginFigureSummaryPart[];
170
+ /** The workspace's home in the console, absolute; `''` with no origin. */
171
+ url: string;
172
+ }): WeeklySummaryEmail | null;
173
+ export {};
@@ -16,23 +16,27 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
16
16
  * limitations under the License.
17
17
  */ import { pluginFigureCellText, pluginFigureIsoWeek, pluginFigureWindows } from "../plugin-manager/plugin-figures.js";
18
18
  /**
19
- * THE WEEKLY SUMMARY (AGL-3867): the free Monday email that says how each of
20
- * a workspace's sites did last week, and how the workspace's own figures
21
- * moved.
19
+ * THE WEEKLY SUMMARY (AGL-3867): the free Monday emails that say how a
20
+ * workspace did the week before — one for each site that had something to
21
+ * report, and an overview of the workspace that adds the sites up.
22
22
  *
23
- * It spends nothing. Every figure comes from a `plugin-figures` reader that
24
- * declares a `weeklySummary`, and that reader's owner words its own lines, so
25
- * each plugin's counting rules decide what a figure is and nothing here names
26
- * one. AI weekly insights (AGL-2915) are the paid version that explains the
27
- * figures, and stay opt-in because each one spends the workspace's AI credits.
23
+ * They spend nothing. Every figure comes from a `plugin-figures` reader that
24
+ * declares a `weeklySummary`, and that reader's owner says what its table
25
+ * means as tiles and ranked rows, so each plugin's counting rules decide what
26
+ * a figure is and nothing here names one. AI weekly insights (AGL-2915) are
27
+ * the paid version that explains the figures, and stay opt-in because each
28
+ * one spends the workspace's AI credits.
28
29
  *
29
- * This module is the period, the shared wording helpers the readers use, and
30
- * the email's words; the reads and the sends are
31
- * `@aglyn/tenant-data-admin/server/weekly-summary`.
30
+ * This module is the period, the wording helpers the readers use, and both
31
+ * emails' words and layout; the reads and the sends are
32
+ * `@aglyn/tenant-data-admin/server/weekly-summary`. The layout is drawn as
33
+ * email-safe tables with inline styles, in colors the caller resolves from
34
+ * the email theme.
32
35
  */ /** The key a person's switch is stored under in `users/{uid}.digestPrefs`; on until turned off. */ export const WEEKLY_SUMMARY_DIGEST_KEY = 'weeklySummary';
33
36
  /** The window every figure is read over. */ export const WEEKLY_SUMMARY_DAYS = 7;
34
- /** Rows a ranked line names: the top three. */ export const WEEKLY_SUMMARY_TOP_ROWS = 3;
35
- /** Counts the subject line leads with, at most. */ export const WEEKLY_SUMMARY_HEADLINE_COUNTS = 3;
37
+ /** Rows a ranked list names. */ export const WEEKLY_SUMMARY_TOP_ROWS = 5;
38
+ /** Counts a subject line or the overview's tiles lead with, at most. */ export const WEEKLY_SUMMARY_HEADLINE_COUNTS = 3;
39
+ /** Tiles in one row of an email. */ const TILES_PER_ROW = 3;
36
40
  /**
37
41
  * The week a run reports on: the seven whole UTC days before the day it runs,
38
42
  * so a Monday run covers Monday to Sunday and never a part-day of today.
@@ -57,6 +61,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
57
61
  timeZone: 'UTC'
58
62
  });
59
63
  }
64
+ // ── Helpers the readers word their parts with ──────────────────────────────
60
65
  /** A cell as a number, `0` for anything that is not one. */ export function figureCount(value) {
61
66
  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
62
67
  }
@@ -66,72 +71,281 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
66
71
  /** Whether a table carries a column, so a reader that changed shape is noticed rather than misread. */ export function figureHasColumn(table, key) {
67
72
  return table.columns.some((column)=>column.key === key);
68
73
  }
69
- /** "1,204 (+12%)": a cell and, when there is an earlier week to compare with, its change. */ export function figureWithChange(table, row, key, changeKey) {
74
+ /** A cell as a tile, with its change when the table has one. */ export function figureStat(table, row, key, label, options = {}) {
70
75
  var _row_key;
71
- const value = pluginFigureCellText(table, key, (_row_key = row[key]) != null ? _row_key : 0);
72
- const change = row[changeKey];
73
- if (typeof change !== 'number') return value;
74
- return `${value} (${change === 0 ? 'no change' : pluginFigureCellText(table, changeKey, change)})`;
75
- }
76
- /** The named rows with the most of `key`, as "name 12 unit, name 4"; `null` when none has any. */ export function figureRanked(table, nameKey, key, unit = '', rows = table.rows) {
77
- const top = rows.filter((row)=>typeof row[nameKey] === 'string' && figureCount(row[key]) > 0).slice(0, WEEKLY_SUMMARY_TOP_ROWS);
78
- if (!top.length) return null;
79
- return top.map((row, index)=>{
76
+ const change = options.change ? row[options.change] : undefined;
77
+ return _extends({
78
+ label,
79
+ value: pluginFigureCellText(table, key, (_row_key = row[key]) != null ? _row_key : 0)
80
+ }, typeof change === 'number' ? {
81
+ change
82
+ } : {}, options.total ? {
83
+ total: _extends({
84
+ count: figureCount(row[key])
85
+ }, options.total)
86
+ } : {});
87
+ }
88
+ /** The named rows with the most of `key`, as ranked rows; `[]` when none has any. */ export function figureRanked(table, nameKey, key, rows = table.rows) {
89
+ return rows.filter((row)=>typeof row[nameKey] === 'string' && figureCount(row[key]) > 0).slice(0, WEEKLY_SUMMARY_TOP_ROWS).map((row)=>{
80
90
  var _row_key;
81
- const amount = pluginFigureCellText(table, key, (_row_key = row[key]) != null ? _row_key : 0);
82
- return `${row[nameKey]} ${amount}${index === 0 && unit ? ` ${unit}` : ''}`;
83
- }).join(', ');
91
+ return {
92
+ label: String(row[nameKey]),
93
+ value: pluginFigureCellText(table, key, (_row_key = row[key]) != null ? _row_key : 0)
94
+ };
95
+ });
84
96
  }
85
97
  /** "1 order", "18 orders". */ export function figurePlural(count, one, many) {
86
98
  return `${count.toLocaleString('en-US')} ${count === 1 ? one : many}`;
87
99
  }
88
- /** The subject's counts, summed across sites by their noun, in the order the readers first gave them. */ export function weeklySummaryHeadline(sites) {
100
+ /** A site with anything to say. */ export function weeklySummaryHasNews(site) {
101
+ return site.parts.length > 0;
102
+ }
103
+ /** The counts the readers offered for adding up, summed by noun, in the order they were first given. */ export function weeklySummaryTotals(sites) {
89
104
  const sums = new Map();
90
105
  for (const site of sites){
91
- for (const { headline } of site.entries){
92
- if (!headline || headline.count <= 0) continue;
93
- const sum = sums.get(headline.one);
94
- sums.set(headline.one, sum ? _extends({}, sum, {
95
- count: sum.count + headline.count
96
- }) : _extends({}, headline));
106
+ for (const part of site.parts){
107
+ var _part_stats;
108
+ for (const stat of (_part_stats = part.stats) != null ? _part_stats : []){
109
+ if (!stat.total || stat.total.count <= 0) continue;
110
+ const sum = sums.get(stat.total.one);
111
+ sums.set(stat.total.one, sum ? _extends({}, sum, {
112
+ count: sum.count + stat.total.count
113
+ }) : _extends({}, stat.total));
114
+ }
97
115
  }
98
116
  }
99
117
  return [
100
118
  ...sums.values()
101
- ].slice(0, WEEKLY_SUMMARY_HEADLINE_COUNTS).map(({ count, one, many })=>figurePlural(count, one, many)).join(', ');
119
+ ];
102
120
  }
103
- export function composeWeeklySummarySubject(workspaceName, sites) {
104
- const headline = weeklySummaryHeadline(sites);
105
- return headline ? `Your week at ${workspaceName}: ${headline}` : `Your week at ${workspaceName}`;
121
+ function headline(sites) {
122
+ return weeklySummaryTotals(sites).slice(0, WEEKLY_SUMMARY_HEADLINE_COUNTS).map(({ count, one, many })=>figurePlural(count, one, many)).join(', ');
106
123
  }
124
+ /** "+12.5%", "−27.9%", "no change"; `null` with nothing to compare with. */ export function weeklySummaryChange(change) {
125
+ if (typeof change !== 'number' || !Number.isFinite(change)) return null;
126
+ if (change === 0) return {
127
+ text: 'no change',
128
+ sign: 0
129
+ };
130
+ const amount = Math.abs(change).toLocaleString('en-US', {
131
+ maximumFractionDigits: 1
132
+ });
133
+ return change > 0 ? {
134
+ text: `+${amount}%`,
135
+ sign: 1
136
+ } : {
137
+ text: `−${amount}%`,
138
+ sign: -1
139
+ };
140
+ }
141
+ const esc = (value)=>value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
142
+ const FONT = 'Helvetica, Arial, sans-serif';
143
+ const capitalized = (value)=>value.charAt(0).toUpperCase() + value.slice(1);
107
144
  /**
108
- * The email's words, or `null` when there is nothing to report at all — a
109
- * week with no figures is not worth an email, and sending one anyway is how a
110
- * summary trains people to ignore it. A site with nothing to say is left out.
111
- */ export function composeWeeklySummaryEmailText(input) {
112
- const sections = input.sites.map((site)=>({
113
- site,
114
- lines: site.entries.flatMap((entry)=>entry.lines)
115
- })).filter(({ lines })=>lines.length > 0);
116
- const workspaceLines = input.workspace.flatMap((entry)=>entry.lines);
117
- if (!sections.length && !workspaceLines.length) return null;
118
- const lines = [
119
- `Here is how ${input.workspaceName} did last week, ${input.periodLabel}. ` + 'A change in brackets is against the week before.'
145
+ * A label that reads like an address — `www.google.com` — kept from being
146
+ * turned into a link by the inbox, which would draw it in the inbox's own
147
+ * blue beside the theme's links. A zero-width space after each dot breaks
148
+ * the detection and nothing a reader sees.
149
+ */ const unlinked = (value)=>esc(value).replace(/\./g, '.\u200b');
150
+ function changeHtml(change, colors) {
151
+ const said = weeklySummaryChange(change);
152
+ if (!said) return '';
153
+ const color = said.sign > 0 ? colors.up : said.sign < 0 ? colors.down : colors.muted;
154
+ const arrow = said.sign > 0 ? '▲ ' : said.sign < 0 ? '▼ ' : '';
155
+ return `<div style="font-family:${FONT};font-size:13px;line-height:1.4;font-weight:600;color:${color};">${arrow}${esc(said.text)}</div>`;
156
+ }
157
+ /**
158
+ * How many tiles share each row: one row of up to three, two rows of two for
159
+ * four, then rows of three — so a row is never left with an empty slot
160
+ * beside two tiles.
161
+ */ export function weeklySummaryTileRows(count) {
162
+ if (count <= 0) return [];
163
+ if (count <= TILES_PER_ROW) return [
164
+ count
120
165
  ];
121
- for (const { site, lines: siteLines } of sections){
122
- lines.push('', site.name, ...siteLines.map((line)=>`- ${line}`));
123
- if (site.url) lines.push(`Open this site in ${input.productName}: ${site.url}`);
124
- }
125
- if (workspaceLines.length) {
126
- lines.push('', `Across ${input.workspaceName}`, ...workspaceLines.map((line)=>`- ${line}`));
127
- }
128
- if (input.aiInsightsUrl) {
129
- lines.push('', `Want to know why the numbers moved? AI weekly insights explain each site's figures every Monday. ` + `Turn them on from Ask about your numbers on a site's Analytics page: ${input.aiInsightsUrl}`);
166
+ if (count === 4) return [
167
+ 2,
168
+ 2
169
+ ];
170
+ const rows = [];
171
+ for(let left = count; left > 0; left -= TILES_PER_ROW)rows.push(Math.min(TILES_PER_ROW, left));
172
+ return rows;
173
+ }
174
+ /** Tiles, each a value, its label and its change, filling the width of their row. */ function tilesHtml(stats, colors) {
175
+ const rows = [];
176
+ let at = 0;
177
+ for (const size of weeklySummaryTileRows(stats.length)){
178
+ const cells = stats.slice(at, at + size);
179
+ at += size;
180
+ // A tile alone takes half the row, not all of it, so it reads as a tile.
181
+ const columns = Math.max(size, 2);
182
+ const width = `${Math.floor(100 / columns)}%`;
183
+ const tds = cells.map((stat, index)=>{
184
+ // The gutter is split between neighbours, so the row's outer tiles sit
185
+ // flush with the column's edges.
186
+ const left = index === 0 ? 0 : 4;
187
+ const right = index === cells.length - 1 ? 0 : 4;
188
+ return `<td style="width:${width};padding:0 ${right}px 8px ${left}px;vertical-align:top;">` + `<div style="background-color:${colors.surface};border:1px solid ${colors.border};border-radius:8px;padding:12px 14px;">` + `<div style="font-family:${FONT};font-size:24px;line-height:1.2;font-weight:700;color:${colors.text};">${esc(stat.value)}</div>` + `<div style="font-family:${FONT};font-size:13px;line-height:1.4;color:${colors.muted};">${esc(stat.label)}</div>` + changeHtml(stat.change, colors) + `</div></td>`;
189
+ });
190
+ while(tds.length < columns)tds.push(`<td style="width:${width};padding:0 0 8px 4px;"></td>`);
191
+ rows.push(`<tr>${tds.join('')}</tr>`);
130
192
  }
131
- if (!input.omitClosing) {
132
- lines.push('', `You get this summary because you manage ${input.workspaceName}. Turn it off in Notifications: ${input.settingsUrl}`);
193
+ return `<table role="presentation" style="width:100%;border-collapse:collapse;table-layout:fixed;">${rows.join('')}</table>`;
194
+ }
195
+ /** A ranked list: the label left, its figure right, a hairline between. */ function rowsHtml(rows, colors) {
196
+ const trs = rows.map((row, index)=>`<tr>` + `<td style="font-family:${FONT};font-size:14px;line-height:1.4;color:${colors.text};padding:8px 12px 8px 0;` + `${index < rows.length - 1 ? `border-bottom:1px solid ${colors.border};` : ''}word-break:break-word;">${unlinked(row.label)}</td>` + `<td style="font-family:${FONT};font-size:14px;line-height:1.4;font-weight:600;color:${colors.text};padding:8px 0;text-align:right;white-space:nowrap;` + `${index < rows.length - 1 ? `border-bottom:1px solid ${colors.border};` : ''}">${esc(row.value)}</td>` + `</tr>`);
197
+ return `<table role="presentation" style="width:100%;border-collapse:collapse;">${trs.join('')}</table>`;
198
+ }
199
+ function sectionTitleHtml(title, colors) {
200
+ return `<div style="font-family:${FONT};font-size:12px;line-height:1.4;font-weight:700;letter-spacing:0.06em;` + `text-transform:uppercase;color:${colors.muted};padding:0 0 8px;">${esc(title)}</div>`;
201
+ }
202
+ function partHtml(part, colors) {
203
+ var _part_stats, _part_rows;
204
+ return `<div style="padding:16px 0 8px;">` + sectionTitleHtml(part.title, colors) + (part.note ? `<div style="font-family:${FONT};font-size:13px;line-height:1.5;color:${colors.muted};padding:0 0 8px;">${esc(part.note)}</div>` : '') + (((_part_stats = part.stats) == null ? void 0 : _part_stats.length) ? tilesHtml(part.stats, colors) : '') + (((_part_rows = part.rows) == null ? void 0 : _part_rows.length) ? rowsHtml(part.rows, colors) : '') + `</div>`;
205
+ }
206
+ /** "aglyn.com · Aglyn LLC": which site, linked, and whose. */ function addressHtml(site, workspaceName, colors) {
207
+ const address = site.address ? `<a href="${esc(site.address.url)}" style="color:${colors.link};text-decoration:underline;font-weight:600;">${esc(site.address.label)}</a>` : `<span style="font-weight:600;">${esc(site.name)}</span>`;
208
+ return `<div style="font-family:${FONT};font-size:14px;line-height:1.5;color:${colors.muted};padding:0 0 4px;">` + `${address} · ${esc(workspaceName)}</div>`;
209
+ }
210
+ function insightsHtml(url, colors) {
211
+ return `<div style="margin:16px 0 0;background-color:${colors.surface};border-left:3px solid ${colors.accent};border-radius:4px;padding:14px 16px;">` + `<div style="font-family:${FONT};font-size:14px;line-height:1.5;font-weight:700;color:${colors.text};">Want to know why the numbers moved?</div>` + `<div style="font-family:${FONT};font-size:14px;line-height:1.5;color:${colors.text};">` + `AI weekly insights explain each site's week in a few plain sentences. ` + `<a href="${esc(url)}" style="color:${colors.link};text-decoration:underline;">Turn them on from Ask about your numbers</a>.` + `</div></div>`;
212
+ }
213
+ function partText(part) {
214
+ var _part_stats, _part_rows;
215
+ const lines = [
216
+ part.title.toUpperCase()
217
+ ];
218
+ if (part.note) lines.push(part.note);
219
+ for (const stat of (_part_stats = part.stats) != null ? _part_stats : []){
220
+ const said = weeklySummaryChange(stat.change);
221
+ lines.push(`${stat.label}: ${stat.value}${said ? ` (${said.text})` : ''}`);
133
222
  }
134
- return lines.join('\n');
223
+ for (const row of (_part_rows = part.rows) != null ? _part_rows : [])lines.push(`- ${row.label}: ${row.value}`);
224
+ return lines;
225
+ }
226
+ function closing(input) {
227
+ return [
228
+ '',
229
+ `You get this summary because you manage ${input.workspaceName}. Turn it off in Notifications: ${input.settingsUrl}`
230
+ ];
231
+ }
232
+ /**
233
+ * One site's email, or `null` for a site with nothing to report. `workspace`
234
+ * carries the workspace's own parts when this site's email stands in for the
235
+ * overview, in a workspace with one active site.
236
+ */ export function composeWeeklySummarySiteEmail(input) {
237
+ var _input_workspace, _input_site_url;
238
+ const workspace = (_input_workspace = input.workspace) != null ? _input_workspace : [];
239
+ if (!weeklySummaryHasNews(input.site) && !workspace.length) return null;
240
+ const lead = headline([
241
+ input.site
242
+ ]);
243
+ const subject = lead ? `${input.site.name} last week: ${lead}` : `${input.site.name} last week`;
244
+ const html = addressHtml(input.site, input.workspaceName, input.colors) + input.site.parts.map((part)=>partHtml(part, input.colors)).join('') + (workspace.length ? `<div style="border-top:1px solid ${input.colors.border};margin:16px 0 0;"></div>` + workspace.map((part)=>partHtml(_extends({}, part, {
245
+ title: `${input.workspaceName} · ${part.title}`
246
+ }), input.colors)).join('') : '') + (input.aiInsightsUrl ? insightsHtml(input.aiInsightsUrl, input.colors) : '');
247
+ const text = [
248
+ `${input.site.name} last week, ${input.periodLabel}. A change in brackets is against the week before.`,
249
+ `Site: ${input.site.address ? `${input.site.address.label} (${input.site.address.url})` : input.site.name} · ${input.workspaceName}`,
250
+ ...input.site.parts.flatMap((part)=>[
251
+ '',
252
+ ...partText(part)
253
+ ]),
254
+ ...workspace.flatMap((part)=>[
255
+ '',
256
+ ...partText(_extends({}, part, {
257
+ title: `${input.workspaceName} · ${part.title}`
258
+ }))
259
+ ]),
260
+ ...input.site.url ? [
261
+ '',
262
+ `Open ${input.site.name} in ${input.productName}: ${input.site.url}`
263
+ ] : [],
264
+ ...input.aiInsightsUrl ? [
265
+ '',
266
+ `Want to know why the numbers moved? Turn on AI weekly insights from Ask about your numbers: ${input.aiInsightsUrl}`
267
+ ] : [],
268
+ ...closing(input)
269
+ ].join('\n');
270
+ return {
271
+ subject,
272
+ title: `${input.site.name} last week`,
273
+ period: `${input.periodLabel}, compared with the week before`,
274
+ html,
275
+ text,
276
+ buttonLabel: `Open ${input.site.name}`,
277
+ url: (_input_site_url = input.site.url) != null ? _input_site_url : ''
278
+ };
279
+ }
280
+ /**
281
+ * The workspace overview: the week's totals across its sites, a line for each
282
+ * site, and the workspace's own figures, or `null` when nothing anywhere has
283
+ * anything to report.
284
+ */ export function composeWeeklySummaryOverviewEmail(input) {
285
+ const active = input.sites.filter(weeklySummaryHasNews);
286
+ if (!active.length && !input.workspace.length) return null;
287
+ const totals = weeklySummaryTotals(active);
288
+ const lead = totals.slice(0, WEEKLY_SUMMARY_HEADLINE_COUNTS).map(({ count, one, many })=>figurePlural(count, one, many)).join(', ');
289
+ const subject = lead ? `Your week at ${input.workspaceName}: ${lead}` : `Your week at ${input.workspaceName}`;
290
+ const { colors } = input;
291
+ const totalsPart = totals.length ? {
292
+ title: active.length === 1 ? 'At a glance' : `Across ${active.length} sites`,
293
+ stats: totals.slice(0, TILES_PER_ROW * 2).map(({ count, one, many })=>({
294
+ label: capitalized(count === 1 ? one : many),
295
+ value: count.toLocaleString('en-US')
296
+ }))
297
+ } : null;
298
+ const siteLine = (site)=>site.parts.flatMap((part)=>{
299
+ var _part_stats;
300
+ return (_part_stats = part.stats) != null ? _part_stats : [];
301
+ }).filter((stat)=>stat.total && stat.total.count > 0).slice(0, WEEKLY_SUMMARY_HEADLINE_COUNTS).map((stat)=>{
302
+ const total = stat.total;
303
+ const said = weeklySummaryChange(stat.change);
304
+ return `${figurePlural(total.count, total.one, total.many)}${said ? ` (${said.text})` : ''}`;
305
+ }).join(' · ');
306
+ const quietCount = input.sites.length - active.length;
307
+ const quiet = quietCount ? `${figurePlural(quietCount, 'other site', 'other sites')} had nothing to report.` : '';
308
+ const sitesHtml = active.length ? `<div style="padding:16px 0 8px;">` + sectionTitleHtml('Your sites', colors) + `<table role="presentation" style="width:100%;border-collapse:collapse;">` + active.map((site, index)=>{
309
+ const border = index < active.length - 1 ? `border-bottom:1px solid ${colors.border};` : '';
310
+ const name = site.url ? `<a href="${esc(site.url)}" style="color:${colors.link};text-decoration:none;font-weight:700;">${esc(site.name)}</a>` : `<span style="font-weight:700;">${esc(site.name)}</span>`;
311
+ const line = siteLine(site);
312
+ const address = site.address ? ` <a href="${esc(site.address.url)}" style="color:${colors.muted};text-decoration:underline;font-size:13px;font-weight:400;">${esc(site.address.label)}</a>` : '';
313
+ return `<tr><td style="font-family:${FONT};font-size:15px;line-height:1.4;color:${colors.text};padding:10px 0;${border}">` + name + address + (line ? `<div style="font-size:13px;line-height:1.5;color:${colors.muted};">${esc(line)}</div>` : '') + `</td></tr>`;
314
+ }).join('') + `</table>` + (quiet ? `<div style="font-family:${FONT};font-size:13px;line-height:1.5;color:${colors.muted};padding:4px 0 0;">${esc(quiet)}</div>` : '') + `</div>` : '';
315
+ const html = (totalsPart ? partHtml(totalsPart, colors) : '') + sitesHtml + input.workspace.map((part)=>partHtml(part, colors)).join('') + (active.length > 1 ? `<div style="font-family:${FONT};font-size:13px;line-height:1.5;color:${colors.muted};padding:8px 0 0;">Each site's own summary arrives beside this one.</div>` : '') + (input.aiInsightsUrl ? insightsHtml(input.aiInsightsUrl, colors) : '');
316
+ const text = [
317
+ `Here is how ${input.workspaceName} did last week, ${input.periodLabel}. A change in brackets is against the week before.`,
318
+ ...totalsPart ? [
319
+ '',
320
+ ...partText(totalsPart)
321
+ ] : [],
322
+ ...active.length ? [
323
+ '',
324
+ 'YOUR SITES',
325
+ ...active.map((site)=>`- ${site.name}${site.address ? ` (${site.address.label})` : ''}: ${siteLine(site) || 'see its summary'}` + (site.url ? ` · ${site.url}` : '')),
326
+ ...quiet ? [
327
+ quiet
328
+ ] : []
329
+ ] : [],
330
+ ...input.workspace.flatMap((part)=>[
331
+ '',
332
+ ...partText(part)
333
+ ]),
334
+ ...input.aiInsightsUrl ? [
335
+ '',
336
+ `Want to know why the numbers moved? Turn on AI weekly insights from Ask about your numbers: ${input.aiInsightsUrl}`
337
+ ] : [],
338
+ ...closing(input)
339
+ ].join('\n');
340
+ return {
341
+ subject,
342
+ title: `${input.workspaceName} last week`,
343
+ period: `${input.periodLabel}, compared with the week before`,
344
+ html,
345
+ text,
346
+ buttonLabel: `Open ${input.workspaceName}`,
347
+ url: input.url
348
+ };
135
349
  }
136
350
 
137
351
  //# sourceMappingURL=weekly-summary.js.map