← 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,
      });
    }));
  },
};