← back to Marketing Command Center
modules/send-times/index.js
373 lines
// Send-Time Optimization module — for a chosen segment, mine historical email
// open timestamps and recommend the best day×hour send window. Read-only; never
// triggers a send. Heatmap is built per-segment from per-contact open history.
//
// Data source: when CTCT_ACCESS_TOKEN is present we'd pull v3 reporting, but that
// endpoint isn't on every CC plan, so this module always operates on a stable
// deterministic mock pool (~200 contacts, each with 12-48 historical opens drawn
// from a persona-shaped day×hour distribution). Every response includes mock:true
// so the panel can show the "mock data" banner.
//
// Segment filtering reuses the segments module's rule evaluator (require it so
// any schema additions there flow through here automatically).
const fs = require('fs');
const path = require('path');
const segmentsMod = require('../segments');
const SEG_FILE = path.join(__dirname, '..', '..', 'data', 'segments.json');
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// ── deterministic PRNG ──────────────────────────────────────────────────────
// mulberry32 — same generator used by the segments mock so behaviour is stable.
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s |= 0; s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── persona day×hour open distributions ─────────────────────────────────────
// Each persona has a probability mass over (day, hour). Sampling from these
// produces realistic-looking individual open timestamps and the aggregate
// heatmap will surface the persona's peak windows.
//
// Trade designers: weekday mornings 9-11 AM, light secondary lunch 12-1 PM.
// Retail: weekend evenings 7-9 PM + weekday lunch 12 PM.
// Hospitality / spec: Mon-Tue 7-9 AM (project-week kickoff).
function personaWeights(persona) {
const W = Array.from({ length: 7 }, () => new Array(24).fill(0.02)); // floor
if (persona === 'trade') {
for (const d of [1, 2, 3, 4]) { // Mon-Thu
W[d][9] = 1.4; W[d][10] = 1.7; W[d][11] = 1.2;
W[d][12] = 0.5; W[d][13] = 0.4;
W[d][14] = 0.7; W[d][15] = 0.6;
}
W[5][10] = 0.6; // Fri morning fade
W[2][10] = 1.9; // Tue 10 AM is the peak day
} else if (persona === 'hospitality') {
for (const d of [1, 2]) { // Mon, Tue
W[d][7] = 1.5; W[d][8] = 1.8; W[d][9] = 1.3;
}
W[3][8] = 0.8; W[4][8] = 0.6;
} else { // retail
W[0][19] = 1.5; W[0][20] = 1.4; W[6][19] = 1.3; W[6][20] = 1.4; // weekend evenings
for (const d of [1, 2, 3, 4, 5]) {
W[d][12] = 0.9; W[d][13] = 0.5;
W[d][20] = 0.7;
}
}
// normalize
let sum = 0;
for (let d = 0; d < 7; d++) for (let h = 0; h < 24; h++) sum += W[d][h];
for (let d = 0; d < 7; d++) for (let h = 0; h < 24; h++) W[d][h] /= sum;
return W;
}
// Sample a (day, hour) cell from a weight matrix using `rnd` for the draw.
function sampleCell(W, rnd) {
const r = rnd();
let acc = 0;
for (let d = 0; d < 7; d++) {
for (let h = 0; h < 24; h++) {
acc += W[d][h];
if (r <= acc) return { day: d, hour: h };
}
}
return { day: 2, hour: 10 };
}
// Build an ISO timestamp for the most-recent past occurrence of (day, hour),
// offset by `weeksBack` whole weeks. Used to synthesise realistic open history.
function isoForDayHour(day, hour, weeksBack) {
const d = new Date();
d.setUTCMinutes(0, 0, 0);
d.setUTCHours(hour);
const dayDelta = (d.getUTCDay() - day + 7) % 7;
d.setUTCDate(d.getUTCDate() - dayDelta - 7 * weeksBack);
return d.toISOString();
}
// ── mock contact pool with open history ─────────────────────────────────────
let _pool = null;
function mockPool() {
if (_pool) return _pool;
const rnd = mulberry32(0x517a8e35); // distinct seed from segments module
const studios = ['atelier-noir', 'maison-blanc', 'verdant-interiors', 'harbor-hospitality',
'lumiere-design', 'oak-and-ash', 'studio-meridian', 'thornfield-co', 'cobalt-interiors',
'gilded-room', 'north-star-design', 'ember-and-stone', 'palette-house', 'crestwood-studio',
'ivory-lane', 'monarch-interiors', 'saffron-studio', 'westbrook-design', 'vellum-co', 'aria-spaces'];
const tlds = ['com', 'co', 'studio', 'design'];
const TAGS = ['grasscloth', 'silk', 'cork', 'mural', 'metallic', 'memo-cta', 'lookbook', 'high-intent', 'vip', 'architect'];
const pick = arr => arr[Math.floor(rnd() * arr.length)];
const pool = [];
for (let i = 0; i < 200; i++) {
const isTrade = rnd() < 0.58;
const type = isTrade ? 'trade' : 'retail';
const lists = [];
if (isTrade) {
lists.push('Trade & Designers');
if (rnd() < 0.35) lists.push('Hospitality / Commercial Spec');
} else {
lists.push('Retail Newsletter');
}
if (rnd() < 0.30) lists.push('Memo Sample Requests');
let persona = 'retail';
if (lists.includes('Hospitality / Commercial Spec')) persona = 'hospitality';
else if (isTrade) persona = 'trade';
const tags = [];
const nTags = Math.floor(rnd() * 3);
for (let t = 0; t < nTags; t++) {
const tg = pick(TAGS);
if (!tags.includes(tg)) tags.push(tg);
}
// 12% never-engaged → no open history at all
const neverEngaged = rnd() < 0.12;
let openHistory = [];
if (!neverEngaged) {
const W = personaWeights(persona);
const nOpens = 12 + Math.floor(rnd() * 37); // 12-48 opens
for (let k = 0; k < nOpens; k++) {
const { day, hour } = sampleCell(W, rnd);
// distribute across ~16 weeks back; recent weeks slightly likelier.
const weeksBack = Math.floor(rnd() * rnd() * 16);
openHistory.push({ day, hour, ts: isoForDayHour(day, hour, weeksBack) });
}
// sort newest first so lastOpen is the head
openHistory.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
}
const lastOpen = openHistory[0] ? openHistory[0].ts : null;
// synthesise lastClick as ~40% of the most recent open
const lastClick = !neverEngaged && rnd() < 0.4 && openHistory[0]
? openHistory[0].ts
: null;
const studio = studios[i % studios.length];
const email = `${pick(['hello', 'studio', 'projects', 'spec', 'design', 'info'])}+${i}@${studio}.${pick(tlds)}`;
pool.push({
email, type, lists, tags,
persona,
lastOpen, lastClick,
opens: openHistory.length,
clicks: lastClick ? Math.max(1, Math.floor(openHistory.length * 0.25)) : 0,
openHistory,
});
}
_pool = pool;
return pool;
}
// ── aggregation ─────────────────────────────────────────────────────────────
function blankMatrix() {
return Array.from({ length: 7 }, () => new Array(24).fill(0));
}
function aggregateHeatmap(contacts) {
const M = blankMatrix();
let totalOpens = 0;
let contactsWithHistory = 0;
for (const c of contacts) {
if (!c.openHistory || !c.openHistory.length) continue;
contactsWithHistory++;
for (const o of c.openHistory) {
M[o.day][o.hour]++;
totalOpens++;
}
}
return { matrix: M, totalOpens, contactsWithHistory };
}
// Find the single best (day, hour) cell and a second-best for backup.
function topCells(matrix, n = 3) {
const cells = [];
for (let d = 0; d < 7; d++) {
for (let h = 0; h < 24; h++) {
cells.push({ day: d, hour: h, count: matrix[d][h] });
}
}
cells.sort((a, b) => b.count - a.count);
return cells.slice(0, n);
}
// Group cells by day to produce a "best hour per day" digest (useful for
// scheduling around a constraint like "must send on Tuesday").
function bestHourPerDay(matrix) {
return matrix.map((row, day) => {
let bestHour = 0, bestCount = -1;
for (let h = 0; h < 24; h++) {
if (row[h] > bestCount) { bestCount = row[h]; bestHour = h; }
}
return { day, dayLabel: DAY_LABELS[day], hour: bestHour, count: bestCount };
});
}
// Per-contact predicted peak — argmax over their own openHistory matrix.
function predictedPeak(contact) {
if (!contact.openHistory || contact.openHistory.length < 3) {
return { day: null, hour: null, confidence: 'low', samples: contact.openHistory ? contact.openHistory.length : 0 };
}
const M = blankMatrix();
for (const o of contact.openHistory) M[o.day][o.hour]++;
let bestDay = 0, bestHour = 0, bestCount = 0;
for (let d = 0; d < 7; d++) {
for (let h = 0; h < 24; h++) {
if (M[d][h] > bestCount) { bestCount = M[d][h]; bestDay = d; bestHour = h; }
}
}
// Confidence: share of opens that land in the top cell.
const share = bestCount / contact.openHistory.length;
let confidence = 'low';
if (share >= 0.35) confidence = 'high';
else if (share >= 0.20) confidence = 'med';
return {
day: bestDay, dayLabel: DAY_LABELS[bestDay],
hour: bestHour,
confidence,
share: +share.toFixed(2),
samples: contact.openHistory.length,
};
}
// ── segment loading ─────────────────────────────────────────────────────────
function loadSegments() {
try { return JSON.parse(fs.readFileSync(SEG_FILE, 'utf8')); }
catch { return []; }
}
// Filter the pool by segment rules. If segmentId == '__all__' or unknown,
// return the full pool.
function filterBySegment(pool, segmentId) {
if (!segmentId || segmentId === '__all__') return { contacts: pool, segment: null };
const seg = loadSegments().find(s => s.id === segmentId);
if (!seg) return { contacts: pool, segment: null };
const match = segmentsMod._matches || (() => true);
return { contacts: pool.filter(c => match(c, seg.rules)), segment: seg };
}
function fmtHour(h) {
if (h == null) return '—';
const am = h < 12;
const h12 = h % 12 || 12;
return `${h12}:00 ${am ? 'AM' : 'PM'}`;
}
function describeCell(cell) {
if (!cell || cell.count === 0) return null;
return {
day: cell.day,
dayLabel: DAY_LABELS[cell.day],
hour: cell.hour,
hourLabel: fmtHour(cell.hour),
count: cell.count,
};
}
// ── module ──────────────────────────────────────────────────────────────────
module.exports = {
id: 'send-times',
title: 'Send-Time Optimization',
icon: '⏰',
_mockPool: mockPool,
_aggregate: aggregateHeatmap,
_predictedPeak: predictedPeak,
mount(router) {
const guard = (handler) => async (req, res) => {
try { await handler(req, res); }
catch (e) {
res.status(500).json({ error: e.message });
}
};
// GET /segments — picker list (saved segments + an "all contacts" sentinel)
router.get('/segments', guard(async (_req, res) => {
const pool = mockPool();
const segs = loadSegments();
const items = [{ id: '__all__', name: 'All contacts (whole pool)', estimatedSize: pool.length }];
for (const s of segs) {
const match = segmentsMod._matches || (() => true);
const count = pool.filter(c => match(c, s.rules)).length;
items.push({ id: s.id, name: s.name, description: s.description, estimatedSize: count });
}
res.json({ mock: true, segments: items });
}));
// GET /heatmap?segmentId=... — day×hour aggregate + recommendations
router.get('/heatmap', guard(async (req, res) => {
const pool = mockPool();
const { contacts, segment } = filterBySegment(pool, String(req.query.segmentId || '__all__'));
const { matrix, totalOpens, contactsWithHistory } = aggregateHeatmap(contacts);
const top = topCells(matrix, 3).map(describeCell).filter(Boolean);
const perDay = bestHourPerDay(matrix);
res.json({
mock: true,
segment: segment ? { id: segment.id, name: segment.name, description: segment.description } : null,
segmentId: req.query.segmentId || '__all__',
contactCount: contacts.length,
contactsWithHistory,
totalOpens,
matrix,
dayLabels: DAY_LABELS,
recommendation: top[0] || null,
backups: top.slice(1),
bestHourPerDay: perDay,
});
}));
// GET /contacts?segmentId=...&limit=50 — per-contact predicted peak window
router.get('/contacts', guard(async (req, res) => {
const pool = mockPool();
const { contacts, segment } = filterBySegment(pool, String(req.query.segmentId || '__all__'));
const limit = Math.max(1, Math.min(500, Number(req.query.limit) || 50));
const rows = contacts
.map(c => {
const peak = predictedPeak(c);
return {
email: c.email,
type: c.type,
lists: c.lists,
tags: c.tags,
opens: c.opens,
clicks: c.clicks,
lastOpen: c.lastOpen,
peak: {
day: peak.day,
dayLabel: peak.dayLabel || null,
hour: peak.hour,
hourLabel: peak.hour == null ? null : fmtHour(peak.hour),
confidence: peak.confidence,
share: peak.share == null ? null : peak.share,
samples: peak.samples,
},
};
})
// Strongest signal first: high-confidence + most samples
.sort((a, b) => {
const rank = x => ({ high: 3, med: 2, low: 1 }[x.peak.confidence] || 0);
if (rank(b) !== rank(a)) return rank(b) - rank(a);
return (b.peak.samples || 0) - (a.peak.samples || 0);
})
.slice(0, limit);
res.json({
mock: true,
segment: segment ? { id: segment.id, name: segment.name } : null,
segmentId: req.query.segmentId || '__all__',
total: contacts.length,
returned: rows.length,
contacts: rows,
});
}));
},
};