← Hearth
Hearth Scoring Updated May 18, 2026

Source Code

The actual scoring functions. No abstraction, no build step. Read it in the Source tab or here.


Every formula in Hearth runs in your browser. No server sees your data. The source tab inside the calculator shows the same functions documented here — both are kept in sync with score.ts.

Open your browser’s Network tab and score 4,000 contacts. Zero outbound requests until you choose to register.

score.ts — core functions

// Burn (integer, min 0 — no cap)
function computeBurn(row, cfg) {
  const days = row.last_send_date
    ? daysBetween(cfg.todayDate, row.last_send_date)
    : 999; // no recency data → full decay pressure
  return Math.max(0,
    (row.sends_last_30  * cfg.sendWeight)
    - (row.clicks_last_30 * cfg.clickWeight)
    - (row.opens_last_30  * cfg.openWeight)
    - (days              * cfg.burnDecayRate)
  );
}

// Recovery (integer, min 0)
function computeRecovery(row, cfg, bands) {
  if (!row.last_gift_date) return 0;
  const spike = lookupSpikeHeight(row.last_gift_amount, bands);
  const days  = daysBetween(cfg.todayDate, row.last_gift_date);
  return Math.max(0, spike - (days * cfg.recoveryDecayRate));
}

// Velocity (0–100)
function computeVelocity(row) {
  if (row.lifetime_giving <= 0) return 0;
  return Math.round(
    Math.min(100, (row.this_year_giving / row.lifetime_giving) * 100)
  );
}

// Heat (0–100)
function computeHeat(row, cfg) {
  if (!row.first_gift_date || cfg.maxAnnualRate <= 0) return 0;
  const years = daysBetween(cfg.todayDate, row.first_gift_date) / 365.25;
  if (years <= 0) return 0;
  const annualRate = row.lifetime_giving / years;
  return Math.round(Math.min(100, (annualRate / cfg.maxAnnualRate) * 100));
}

// Quadrant
function assignQuadrant(velocity, heat, threshold) {
  const highV = velocity >= threshold;
  const highH = heat     >= threshold;
  if (highV && highH)  return 'Prime';
  if (highV && !highH) return 'Rising';
  if (!highV && highH) return 'Cooling';
  return               'Dormant';
}

Gift band lookup

const DEFAULT_GIFT_BANDS = [
  { minAmount: 10000, spikeHeight: 100 },
  { minAmount:  5000, spikeHeight:  80 },
  { minAmount:  1000, spikeHeight:  60 },
  { minAmount:   100, spikeHeight:  35 },
  { minAmount:     0, spikeHeight:  20 },
];

function lookupSpikeHeight(amount, bands) {
  for (const band of bands) {
    if (amount >= band.minAmount) return band.spikeHeight;
  }
  return 0;
}

Bands walk from highest to lowest. First match wins. Configure your own bands in the Config tab.