mango-lollipop 0.1.0

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/lib/excel.ts ADDED
@@ -0,0 +1,433 @@
1
+ // =============================================================================
2
+ // Mango Lollipop — Excel Generation (xlsx-js-style)
3
+ // =============================================================================
4
+
5
+ import XLSX from "xlsx-js-style";
6
+ import { writeFileSync } from "fs";
7
+ import type {
8
+ Message,
9
+ EventTaxonomy,
10
+ Channel,
11
+ AARRRStage,
12
+ Analysis,
13
+ } from "./schema.js";
14
+
15
+ // -----------------------------------------------------------------------------
16
+ // Stage display names and colors
17
+ // -----------------------------------------------------------------------------
18
+
19
+ const STAGE_LABELS: Record<AARRRStage | "TX", string> = {
20
+ TX: "Transactional",
21
+ AQ: "Acquisition",
22
+ AC: "Activation",
23
+ RV: "Revenue",
24
+ RT: "Retention",
25
+ RF: "Referral",
26
+ };
27
+
28
+ const STAGE_COLORS: Record<string, { fill: string }> = {
29
+ TX: { fill: "F0F0F0" },
30
+ AQ: { fill: "D4EDDA" },
31
+ AC: { fill: "CCE5FF" },
32
+ RV: { fill: "FFF3CD" },
33
+ RT: { fill: "FFE5CC" },
34
+ RF: { fill: "E8D5F5" },
35
+ };
36
+
37
+ const HEADER_STYLE: XLSX.CellStyle = {
38
+ fill: { fgColor: { rgb: "374151" } },
39
+ font: { bold: true, color: { rgb: "FFFFFF" }, sz: 11 },
40
+ alignment: { vertical: "center" },
41
+ };
42
+
43
+ // -----------------------------------------------------------------------------
44
+ // Helpers
45
+ // -----------------------------------------------------------------------------
46
+
47
+ function guardsToString(msg: Message): string {
48
+ if (!msg.guards.length) return "\u2014";
49
+ return msg.guards.map((g) => g.condition).join("; ");
50
+ }
51
+
52
+ function suppressionsToString(msg: Message): string {
53
+ if (!msg.suppressions.length) return "\u2014";
54
+ return msg.suppressions.map((s) => s.condition).join("; ");
55
+ }
56
+
57
+ function channelToString(msg: Message): string {
58
+ if (msg.channel) return msg.channel;
59
+ if ((msg as any).channels) return (msg as any).channels.join(", ");
60
+ return "\u2014";
61
+ }
62
+
63
+ function tagsToString(tags: string[]): string {
64
+ return tags.join(", ");
65
+ }
66
+
67
+ // Apply header style to first row of a worksheet
68
+ function applyHeaderStyle(ws: XLSX.WorkSheet): void {
69
+ const range = XLSX.utils.decode_range(ws["!ref"] ?? "A1");
70
+ for (let c = range.s.c; c <= range.e.c; c++) {
71
+ const addr = XLSX.utils.encode_cell({ r: 0, c });
72
+ if (ws[addr]) {
73
+ ws[addr].s = HEADER_STYLE;
74
+ }
75
+ }
76
+ }
77
+
78
+ // Apply stage-based row fills to data rows
79
+ function applyStageColors(
80
+ ws: XLSX.WorkSheet,
81
+ stageColIndex: number
82
+ ): void {
83
+ const range = XLSX.utils.decode_range(ws["!ref"] ?? "A1");
84
+ for (let r = 1; r <= range.e.r; r++) {
85
+ const stageCell =
86
+ ws[XLSX.utils.encode_cell({ r, c: stageColIndex })];
87
+ if (!stageCell) continue;
88
+
89
+ // Resolve stage code from label or raw value
90
+ const val = String(stageCell.v ?? "");
91
+ let stageCode = val;
92
+ for (const [code, label] of Object.entries(STAGE_LABELS)) {
93
+ if (label === val) {
94
+ stageCode = code;
95
+ break;
96
+ }
97
+ }
98
+
99
+ const color = STAGE_COLORS[stageCode];
100
+ if (!color) continue;
101
+
102
+ const fillStyle: XLSX.CellStyle = {
103
+ fill: { fgColor: { rgb: color.fill } },
104
+ };
105
+
106
+ for (let c = range.s.c; c <= range.e.c; c++) {
107
+ const addr = XLSX.utils.encode_cell({ r, c });
108
+ if (ws[addr]) {
109
+ ws[addr].s = fillStyle;
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ // -----------------------------------------------------------------------------
116
+ // Sheet builders
117
+ // -----------------------------------------------------------------------------
118
+
119
+ function buildWelcomeSheet(
120
+ analysis: Analysis,
121
+ messageCount: number
122
+ ): XLSX.WorkSheet {
123
+ const pathLabel = analysis.path === "fresh" ? "Fresh" : "Improving Existing";
124
+ const channels = analysis.channels.join(", ");
125
+ const generated = new Date().toLocaleDateString("en-US", {
126
+ year: "numeric",
127
+ month: "long",
128
+ day: "numeric",
129
+ });
130
+
131
+ const data: (string | number)[][] = [
132
+ ["Mango Lollipop \u2014 Lifecycle Messaging Matrix"],
133
+ [],
134
+ ["Company", analysis.company.name],
135
+ ["Product", analysis.company.product_type],
136
+ ["Channels", channels],
137
+ ["Generated", generated],
138
+ ["Total Messages", messageCount],
139
+ ["Path", pathLabel],
140
+ [],
141
+ ["How to Use This Spreadsheet"],
142
+ [
143
+ "Transactional Messages",
144
+ "Mandatory system messages (email verification, password reset, receipts)",
145
+ ],
146
+ [
147
+ "Lifecycle Matrix",
148
+ "Your AARRR messaging strategy with triggers, guards, and suppressions",
149
+ ],
150
+ [
151
+ "Event Taxonomy",
152
+ "All product events and which messages they trigger",
153
+ ],
154
+ ["Tags", "Tag inventory with message counts"],
155
+ [
156
+ "Channel Strategy",
157
+ "Message distribution by channel and stage",
158
+ ],
159
+ [],
160
+ [],
161
+ [
162
+ "Generated by Mango Lollipop \u2014 https://github.com/sr-kai/mango-lollipop",
163
+ ],
164
+ ["Made by Sasha Kai with probably too much coffee."],
165
+ ];
166
+
167
+ const ws = XLSX.utils.aoa_to_sheet(data);
168
+
169
+ // Style the title row
170
+ if (ws["A1"]) {
171
+ ws["A1"].s = {
172
+ font: { bold: true, sz: 16, color: { rgb: "1F2937" } },
173
+ };
174
+ }
175
+
176
+ // Style "How to Use" header
177
+ if (ws["A10"]) {
178
+ ws["A10"].s = {
179
+ font: { bold: true, sz: 13, color: { rgb: "1F2937" } },
180
+ };
181
+ }
182
+
183
+ // Style info labels (rows 3-8, column A)
184
+ for (let r = 2; r <= 7; r++) {
185
+ const addr = XLSX.utils.encode_cell({ r, c: 0 });
186
+ if (ws[addr]) {
187
+ ws[addr].s = { font: { bold: true, color: { rgb: "6B7280" } } };
188
+ }
189
+ }
190
+
191
+ // Style sheet tab names (rows 11-15, column A)
192
+ for (let r = 10; r <= 14; r++) {
193
+ const addr = XLSX.utils.encode_cell({ r, c: 0 });
194
+ if (ws[addr]) {
195
+ ws[addr].s = { font: { bold: true, color: { rgb: "374151" } } };
196
+ }
197
+ }
198
+
199
+ // Style footer
200
+ if (ws["A18"]) {
201
+ ws["A18"].s = { font: { color: { rgb: "9CA3AF" }, sz: 10 } };
202
+ }
203
+ if (ws["A19"]) {
204
+ ws["A19"].s = { font: { color: { rgb: "9CA3AF" }, sz: 10 } };
205
+ }
206
+
207
+ // Column widths
208
+ ws["!cols"] = [{ wch: 30 }, { wch: 70 }];
209
+
210
+ return ws;
211
+ }
212
+
213
+ function buildTransactionalSheet(messages: Message[]): XLSX.WorkSheet {
214
+ const tx = messages.filter((m) => m.classification === "transactional");
215
+ const rows = tx.map((m) => ({
216
+ ID: m.id,
217
+ Name: m.name,
218
+ "Trigger Event": m.trigger.event,
219
+ "Trigger Type": m.trigger.type,
220
+ Wait: m.wait,
221
+ Channel: channelToString(m),
222
+ CTA: m.cta.text,
223
+ From: m.from,
224
+ Tags: tagsToString(m.tags),
225
+ }));
226
+ const ws = XLSX.utils.json_to_sheet(rows);
227
+ autoFitColumns(ws, rows);
228
+ applyHeaderStyle(ws);
229
+ // TX rows all get the TX color
230
+ applyStageColors(ws, -1); // special handling below
231
+ // For TX sheet, all rows are TX — apply fill to all data rows
232
+ const range = XLSX.utils.decode_range(ws["!ref"] ?? "A1");
233
+ const txFill: XLSX.CellStyle = {
234
+ fill: { fgColor: { rgb: STAGE_COLORS.TX.fill } },
235
+ };
236
+ for (let r = 1; r <= range.e.r; r++) {
237
+ for (let c = range.s.c; c <= range.e.c; c++) {
238
+ const addr = XLSX.utils.encode_cell({ r, c });
239
+ if (ws[addr]) ws[addr].s = txFill;
240
+ }
241
+ }
242
+ return ws;
243
+ }
244
+
245
+ function buildLifecycleSheet(messages: Message[]): XLSX.WorkSheet {
246
+ const lc = messages.filter((m) => m.classification === "lifecycle");
247
+ const rows = lc.map((m) => ({
248
+ ID: m.id,
249
+ Stage: STAGE_LABELS[m.stage] ?? m.stage,
250
+ Name: m.name,
251
+ "Trigger Event": m.trigger.event,
252
+ "Trigger Type": m.trigger.type,
253
+ Wait: m.wait,
254
+ Guards: guardsToString(m),
255
+ Suppressions: suppressionsToString(m),
256
+ Channel: channelToString(m),
257
+ CTA: m.cta.text,
258
+ Goal: m.goal,
259
+ Segment: m.segment,
260
+ Tags: tagsToString(m.tags),
261
+ Format: m.format,
262
+ From: m.from,
263
+ }));
264
+ const ws = XLSX.utils.json_to_sheet(rows);
265
+ autoFitColumns(ws, rows);
266
+ applyHeaderStyle(ws);
267
+ applyStageColors(ws, 1); // Stage is column B (index 1)
268
+ return ws;
269
+ }
270
+
271
+ function buildEventTaxonomySheet(
272
+ events: EventTaxonomy,
273
+ messages: Message[]
274
+ ): XLSX.WorkSheet {
275
+ const rows: { Category: string; Event: string; "Used By": string }[] = [];
276
+
277
+ const categories: (keyof EventTaxonomy)[] = [
278
+ "identity",
279
+ "activation",
280
+ "engagement",
281
+ "conversion",
282
+ "retention",
283
+ ];
284
+
285
+ for (const category of categories) {
286
+ const eventList = events[category] ?? [];
287
+ for (const event of eventList) {
288
+ const usedBy = messages
289
+ .filter((m) => m.trigger.event === event)
290
+ .map((m) => m.id)
291
+ .join(", ");
292
+ rows.push({
293
+ Category: category.charAt(0).toUpperCase() + category.slice(1),
294
+ Event: event,
295
+ "Used By": usedBy || "\u2014",
296
+ });
297
+ }
298
+ }
299
+
300
+ const ws = XLSX.utils.json_to_sheet(rows);
301
+ autoFitColumns(ws, rows);
302
+ applyHeaderStyle(ws);
303
+ return ws;
304
+ }
305
+
306
+ function buildTagsSheet(
307
+ messages: Message[],
308
+ tagDefinitions: string[]
309
+ ): XLSX.WorkSheet {
310
+ const allTags = new Set<string>([
311
+ ...tagDefinitions,
312
+ ...messages.flatMap((m) => m.tags),
313
+ ]);
314
+
315
+ const rows = Array.from(allTags)
316
+ .sort()
317
+ .map((tag) => {
318
+ const msgIds = messages
319
+ .filter((m) => m.tags.includes(tag))
320
+ .map((m) => m.id);
321
+ return {
322
+ Tag: tag,
323
+ "Message Count": msgIds.length,
324
+ "Used By": msgIds.join(", ") || "\u2014",
325
+ };
326
+ });
327
+
328
+ const ws = XLSX.utils.json_to_sheet(rows);
329
+ autoFitColumns(ws, rows);
330
+ applyHeaderStyle(ws);
331
+ return ws;
332
+ }
333
+
334
+ function buildChannelStrategySheet(messages: Message[]): XLSX.WorkSheet {
335
+ const channels: Channel[] = ["email", "sms", "in-app", "push"];
336
+ const stages: (AARRRStage | "TX")[] = ["TX", "AQ", "AC", "RV", "RT", "RF"];
337
+
338
+ const rows: Record<string, string | number>[] = [];
339
+
340
+ for (const ch of channels) {
341
+ const msgsWithChannel = messages.filter(
342
+ (m) =>
343
+ m.channel === ch ||
344
+ ((m as any).channels && (m as any).channels.includes(ch))
345
+ );
346
+ if (msgsWithChannel.length === 0) continue;
347
+
348
+ const row: Record<string, string | number> = {
349
+ Channel: ch,
350
+ "Total Messages": msgsWithChannel.length,
351
+ };
352
+
353
+ for (const stage of stages) {
354
+ const count = msgsWithChannel.filter((m) => m.stage === stage).length;
355
+ row[STAGE_LABELS[stage]] = count;
356
+ }
357
+
358
+ rows.push(row);
359
+ }
360
+
361
+ const ws = XLSX.utils.json_to_sheet(rows);
362
+ autoFitColumns(ws, rows);
363
+ applyHeaderStyle(ws);
364
+ return ws;
365
+ }
366
+
367
+ // Auto-fit column widths based on content
368
+ function autoFitColumns(
369
+ ws: XLSX.WorkSheet,
370
+ rows: Record<string, unknown>[]
371
+ ): void {
372
+ if (!rows.length) return;
373
+ const headers = Object.keys(rows[0]);
374
+ ws["!cols"] = headers.map((h) => {
375
+ let maxWidth = h.length;
376
+ for (const row of rows) {
377
+ const val = String(row[h] ?? "");
378
+ if (val.length > maxWidth) maxWidth = val.length;
379
+ }
380
+ return { wch: Math.min(maxWidth + 2, 60) };
381
+ });
382
+ }
383
+
384
+ // -----------------------------------------------------------------------------
385
+ // Public API
386
+ // -----------------------------------------------------------------------------
387
+
388
+ export function generateMatrixWorkbook(
389
+ messages: Message[],
390
+ events: EventTaxonomy,
391
+ tags: string[],
392
+ analysis?: Analysis
393
+ ): XLSX.WorkBook {
394
+ const wb = XLSX.utils.book_new();
395
+
396
+ // Welcome sheet first (if analysis data available)
397
+ if (analysis) {
398
+ XLSX.utils.book_append_sheet(
399
+ wb,
400
+ buildWelcomeSheet(analysis, messages.length),
401
+ "Welcome"
402
+ );
403
+ }
404
+
405
+ XLSX.utils.book_append_sheet(
406
+ wb,
407
+ buildTransactionalSheet(messages),
408
+ "Transactional Messages"
409
+ );
410
+ XLSX.utils.book_append_sheet(
411
+ wb,
412
+ buildLifecycleSheet(messages),
413
+ "Lifecycle Matrix"
414
+ );
415
+ XLSX.utils.book_append_sheet(
416
+ wb,
417
+ buildEventTaxonomySheet(events, messages),
418
+ "Event Taxonomy"
419
+ );
420
+ XLSX.utils.book_append_sheet(wb, buildTagsSheet(messages, tags), "Tags");
421
+ XLSX.utils.book_append_sheet(
422
+ wb,
423
+ buildChannelStrategySheet(messages),
424
+ "Channel Strategy"
425
+ );
426
+
427
+ return wb;
428
+ }
429
+
430
+ export function writeWorkbook(workbook: XLSX.WorkBook, filePath: string): void {
431
+ const buf = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" });
432
+ writeFileSync(filePath, buf);
433
+ }