← back to Marketing Command Center

modules/segment-perf/index.js

308 lines

// Segment Performance module — splits email KPIs by audience segment so the
// marketing lead can see at a glance which cohorts are healthy vs. tanking and
// where revenue per recipient actually lives. Read-only analysis; never sends.
//
// KPIs surfaced per segment:
//   • open rate            (opens / sends)
//   • CTOR                 (clicks / opens — engagement quality of openers)
//   • click rate           (clicks / sends — overall response)
//   • est revenue / recipient   (orders × AOV / sends)
//
// Each KPI is colour-coded against luxury B2B email benchmarks (see HEALTH). A
// 90-day daily trend per segment + metric powers the SVG bar chart panel-side.
//
// Data: deterministic mock (CTCT reporting + Shopify revenue attribution not
// wired here). Every response carries { mock: true } so the panel can banner.
//
// The four segments mirror DW's real audience lens:
//   designers     — interior designers / trade (mainstream luxury residential)
//   commercial    — hospitality, healthcare, corporate spec / Type II vinyls
//   retail        — direct-to-consumer newsletter readers
//   at-risk       — historically engaged contacts who've gone cold (winback)

const SEGMENTS = [
  {
    id: 'designers',
    name: 'Interior Designers',
    description: 'Trade designers + residential specifiers — the core luxury book of business.',
    icon: '🪷',
    listSize: 2840,
    sendsPerCampaign: 2620,
    // realistic baselines for a strong trade list
    baseOpenRate: 0.42,   // 42%
    baseCTOR:     0.155,  // 15.5%
    aov:          1480,   // avg order value for a trade specifier
    orderRate:    0.014,  // 1.4% of recipients buy within attribution window
  },
  {
    id: 'commercial',
    name: 'Commercial / Hospitality',
    description: 'Architects + hospitality / healthcare spec — fewer eyes but high project value.',
    icon: '🏛',
    listSize: 612,
    sendsPerCampaign: 590,
    baseOpenRate: 0.385,
    baseCTOR:     0.122,
    aov:          6200,   // big contract project
    orderRate:    0.006,  // rare but huge
  },
  {
    id: 'retail',
    name: 'Retail',
    description: 'Direct-to-consumer newsletter — broadest list, lowest intent.',
    icon: '🛍',
    listSize: 7415,
    sendsPerCampaign: 7080,
    baseOpenRate: 0.275,
    baseCTOR:     0.072,
    aov:          340,
    orderRate:    0.0085,
  },
  {
    id: 'at-risk',
    name: 'At-Risk (Winback)',
    description: 'Previously engaged contacts that have gone cold — winback campaigns target these.',
    icon: '⚠',
    listSize: 1190,
    sendsPerCampaign: 1150,
    baseOpenRate: 0.082,
    baseCTOR:     0.034,
    aov:          410,
    orderRate:    0.0011,
  },
];

// Health thresholds — luxury B2B email standards. Anything below `bad` is red,
// between bad and good is amber, at-or-above good is green. Higher-is-better
// for every metric we track.
const HEALTH = {
  openRate:   { bad: 0.20, good: 0.35 },
  ctor:       { bad: 0.08, good: 0.13 },
  clickRate:  { bad: 0.025, good: 0.055 },
  revPerRecipient: { bad: 0.50, good: 2.00 },
};

function healthBand(metric, value) {
  const h = HEALTH[metric];
  if (!h) return 'unknown';
  if (value < h.bad) return 'bad';
  if (value < h.good) return 'ok';
  return 'good';
}

// ── deterministic PRNG (mulberry32) — same generator used by the other mock
// modules in this app so behaviour stays stable across calls.
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;
  };
}
function hashSeed(str) {
  let h = 2166136261 >>> 0;
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);
    h = Math.imul(h, 16777619) >>> 0;
  }
  return h >>> 0;
}

const pad = n => String(n).padStart(2, '0');
const isoDay = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
function dayList(days, end = new Date()) {
  const out = [];
  const base = new Date(end.getFullYear(), end.getMonth(), end.getDate());
  for (let i = days - 1; i >= 0; i--) {
    out.push(isoDay(new Date(base.getTime() - i * 86400000)));
  }
  return out;
}

// Generate a 90-day trend for one segment+metric. Approach: ~9 campaigns spaced
// evenly across the window (one every ~10 days). Each campaign yields a per-send
// metric value; non-campaign days inherit the previous campaign's value so the
// SVG bars read as a step-down history. At-Risk gets a slight downward drift to
// look like an audience genuinely going cold.
function trendFor(segment, metric, days = 90) {
  const dates = dayList(days);
  const rnd = mulberry32(hashSeed(`${segment.id}|${metric}|v2`));
  const nCampaigns = Math.max(2, Math.round(days / 10));
  // pick campaign dates (indices into `dates`) — evenly distributed
  const campIdxs = [];
  for (let i = 0; i < nCampaigns; i++) {
    campIdxs.push(Math.round(((i + 0.5) / nCampaigns) * (days - 1)));
  }

  // Step 1: compute campaign-day values
  const campValues = campIdxs.map((idx, i) => {
    // jitter ±18% on the base rate
    const jitter = 0.82 + 0.36 * rnd();
    // slight directional drift across the window
    const driftSlope =
      segment.id === 'at-risk' ? -0.25 :       // going cold
      segment.id === 'commercial' ? +0.12 :   // tightening up
      segment.id === 'designers' ? +0.05 :    // mild improvement
      -0.04;                                   // retail mostly flat
    const drift = 1 + driftSlope * ((i / Math.max(1, nCampaigns - 1)) - 0.5);

    let value;
    if (metric === 'openRate') {
      value = segment.baseOpenRate * jitter * drift;
    } else if (metric === 'ctor') {
      value = segment.baseCTOR * jitter * drift;
    } else if (metric === 'clickRate') {
      // click rate = open rate × CTOR with their own jitter
      const o = segment.baseOpenRate * (0.85 + 0.30 * rnd());
      const c = segment.baseCTOR    * (0.85 + 0.30 * rnd());
      value = o * c * drift;
    } else if (metric === 'revPerRecipient') {
      // expected dollars per recipient = orderRate × AOV, with project-deal noise
      const orderRate = segment.orderRate * (0.6 + 1.0 * rnd()) * drift;
      value = orderRate * segment.aov;
    } else {
      value = 0;
    }
    return value;
  });

  // Step 2: build per-day series by holding the last campaign value
  const series = [];
  let lastVal = campValues[0];
  let lastCampIdx = -1;
  for (let i = 0; i < days; i++) {
    const ci = campIdxs.indexOf(i);
    if (ci >= 0) { lastVal = campValues[ci]; lastCampIdx = ci; }
    series.push({
      date: dates[i],
      value: +lastVal.toFixed(metric === 'revPerRecipient' ? 3 : 4),
      isCampaign: ci >= 0,
      campaignIndex: lastCampIdx,
    });
  }

  const mean = campValues.reduce((a, b) => a + b, 0) / campValues.length;
  // 30-day trend: avg of last 30 campaign-day values vs first 30
  const last30 = series.slice(-30).map(s => s.value);
  const first30 = series.slice(0, 30).map(s => s.value);
  const lastAvg = last30.reduce((a, b) => a + b, 0) / last30.length;
  const firstAvg = first30.reduce((a, b) => a + b, 0) / first30.length;
  const pctChange = firstAvg ? +(((lastAvg - firstAvg) / firstAvg) * 100).toFixed(1) : 0;

  return { series, mean: +mean.toFixed(4), campaignDays: campIdxs.map(i => dates[i]), pctChange };
}

// Headline KPI block for one segment — uses the last-30-day average from each
// metric's trend so the headline matches the chart.
function kpisFor(segment) {
  const openTrend = trendFor(segment, 'openRate');
  const ctorTrend = trendFor(segment, 'ctor');
  const clickTrend = trendFor(segment, 'clickRate');
  const revTrend = trendFor(segment, 'revPerRecipient');

  // 30-day windowed averages (chart-aligned)
  const avg = arr => arr.reduce((a, b) => a + b, 0) / arr.length;
  const openRate  = avg(openTrend.series.slice(-30).map(s => s.value));
  const ctor      = avg(ctorTrend.series.slice(-30).map(s => s.value));
  const clickRate = avg(clickTrend.series.slice(-30).map(s => s.value));
  const revPerRecipient = avg(revTrend.series.slice(-30).map(s => s.value));

  return {
    openRate:        { value: +openRate.toFixed(4),  band: healthBand('openRate', openRate),       trend: openTrend.pctChange  },
    ctor:            { value: +ctor.toFixed(4),      band: healthBand('ctor', ctor),               trend: ctorTrend.pctChange  },
    clickRate:       { value: +clickRate.toFixed(4), band: healthBand('clickRate', clickRate),     trend: clickTrend.pctChange },
    revPerRecipient: { value: +revPerRecipient.toFixed(3), band: healthBand('revPerRecipient', revPerRecipient), trend: revTrend.pctChange },
  };
}

// Overall band: worst single KPI drives the segment-level health colour, so the
// summary card honestly flags problem cohorts even if 3 of 4 metrics look fine.
function overallBand(kpis) {
  const order = { bad: 0, ok: 1, good: 2, unknown: 3 };
  return Object.values(kpis).reduce((worst, k) =>
    (order[k.band] < order[worst] ? k.band : worst), 'good');
}

// ── module ─────────────────────────────────────────────────────────────────────
module.exports = {
  id: 'segment-perf',
  title: 'Segment Performance',
  icon: '📈',

  // exposed for unit tests / sibling modules if they ever need to reuse the model
  _segments: SEGMENTS,
  _health: HEALTH,
  _trendFor: trendFor,

  mount(router) {
    // GET /summary — one card's worth of data per segment
    router.get('/summary', (_req, res) => {
      const segments = SEGMENTS.map(s => {
        const kpis = kpisFor(s);
        return {
          id: s.id,
          name: s.name,
          description: s.description,
          icon: s.icon,
          listSize: s.listSize,
          sendsPerCampaign: s.sendsPerCampaign,
          kpis,
          overallBand: overallBand(kpis),
        };
      });
      res.json({
        mock: true,
        generatedAt: new Date().toISOString(),
        benchmarks: HEALTH,
        segments,
      });
    });

    // GET /trend?segmentId=designers&metric=openRate&days=90 — bar chart series
    router.get('/trend', (req, res) => {
      const segmentId = String(req.query.segmentId || '');
      const metric = String(req.query.metric || 'openRate');
      const days = Math.max(7, Math.min(180, Number(req.query.days) || 90));

      const seg = SEGMENTS.find(s => s.id === segmentId);
      if (!seg) {
        return res.status(404).json({
          ok: false,
          error: `unknown segment "${segmentId}"`,
          allowed: SEGMENTS.map(s => s.id),
        });
      }
      const allowedMetrics = ['openRate', 'ctor', 'clickRate', 'revPerRecipient'];
      if (!allowedMetrics.includes(metric)) {
        return res.status(400).json({
          ok: false,
          error: `unknown metric "${metric}"`,
          allowed: allowedMetrics,
        });
      }

      const t = trendFor(seg, metric, days);
      const benchmark = HEALTH[metric];
      res.json({
        mock: true,
        segmentId,
        segmentName: seg.name,
        metric,
        days,
        benchmark,                       // { bad, good }
        mean: t.mean,
        pctChange: t.pctChange,          // last 30d vs first 30d
        campaignDays: t.campaignDays,
        series: t.series,
      });
    });

    // GET /benchmarks — just the thresholds (panel uses these to colour the legend)
    router.get('/benchmarks', (_req, res) => {
      res.json({ mock: true, benchmarks: HEALTH });
    });
  },
};