How to Track Follow-Ups in Google Sheets and Get a Daily Reminder Email (2026)

Most lost deals, slow hires and forgotten client check-ins come down to the same thing: nobody got back to someone when they said they would. If you keep leads, clients, candidates or vendors in a spreadsheet, it already holds the answer to “who do I need to contact today?” It just doesn't tell you. This guide adds a Next follow-up column that fills itself in from the last contact date, a “due today” view, row colors for anything overdue, and a free Apps Script that emails each owner one short list every morning, with a second nudge for anything still untouched three days later.

It's built for a rolling date that changes every time you reach out. For fixed deadlines like renewals and expirations, see email reminders from a date column; for task lists, see overdue task highlighting.

Step 1: a Follow-ups tab

Create a tab named Follow-ups with these headers in row 1. The script finds columns by name, but the formulas below assume this order (A to J):

ColHeaderWhat goes in it
ANameThe person you're following up with
BCompanyOptional; shown next to the name in the email
CEmailTheir address, for your reference. The script never emails them.
DStagee.g. New, Contacted, Proposal sent, Won, Lost (Step 4 adds a dropdown)
ELast contactThe date you last reached out or heard back
FCadence (days)How many business days to wait before the next touch, e.g. 3 or 10
GNext follow-upA formula (Step 2), or a date you type in
HOwner emailWho should get the reminder; blank sends it to you
INotesWhat you're waiting on (“asked for revised pricing”); the first 120 characters appear in the email
JSent logLeave empty; the script records what it sent

Format E and G as dates (Format › Number › Date). To stamp today's date in Last contact quickly, select the cell and press Ctrl+; (⌘+; on a Mac). Don't use =TODAY() there, because it changes every day.

Step 2: the next follow-up date

In G2, fill down. WORKDAY counts business days and skips weekends, so a 3-day cadence after a Thursday call lands on Tuesday:

=IF(OR(E2="",F2=""),"",WORKDAY(E2,F2))

For calendar days instead, use =IF(OR(E2="",F2=""),"",E2+F2). To skip holidays too, list them on a tab named Holidays and use WORKDAY(E2,F2,Holidays!A:A). When someone says “call me after the 20th,” just type that date over the formula in G.

The loop is simple: after each call or email, update Last contact (and the cadence if it should change), and the next date moves forward on its own.

Step 3: a “who to contact today” view

On a new tab, this lists everyone due today or overdue, oldest first, leaving out closed stages:

=SORT(FILTER({'Follow-ups'!A2:A,'Follow-ups'!B2:B,'Follow-ups'!D2:D,'Follow-ups'!G2:G,'Follow-ups'!I2:I},
  'Follow-ups'!G2:G<>"",'Follow-ups'!G2:G<=TODAY(),
  NOT(REGEXMATCH('Follow-ups'!D2:D,"(?i)^(won|lost|closed|not interested|do not contact)$"))),4,TRUE)

The tab name needs single quotes because of the hyphen. A FILTER that finds nothing shows #N/A; wrap it in IFERROR(…,"All caught up") if you like. Format the fourth column as a date. For a count on a dashboard: =COUNTIFS('Follow-ups'!G2:G,"<="&TODAY(),'Follow-ups'!D2:D,"<>Won",'Follow-ups'!D2:D,"<>Lost").

Step 4: a stage dropdown and row colors

Select D2:D, choose Insert › Dropdown, and add your stages, including the closed ones (Won, Lost, Closed, Not interested, Do not contact). Consistent spelling matters, because the script and the view above match those words.

Then select A2:J, open Format › Conditional formatting, pick Custom formula is, and add these rules. The $ before G keeps every cell in the row looking at the same date:

Custom formulaColorMeaning
=AND(ISNUMBER($G2),$G2<TODAY(),NOT(REGEXMATCH($D2,"(?i)^(won|lost|closed)$")))RedOverdue
=AND(ISNUMBER($G2),$G2=TODAY())YellowDue today
=REGEXMATCH($D2,"(?i)^(won|lost|closed)$")GrayClosed

Step 5: a morning email with today's follow-ups

Sheets can't send an email when a date arrives on its own; its notification rules react to edits, not the calendar. A short Apps Script on a daily trigger does it. Open Extensions › Apps Script, paste this, set DEFAULT_TO (or leave it blank to email yourself), save, run sendFollowUpDigest once to approve permissions, then add a trigger: Triggers › Add trigger › sendFollowUpDigest › Time-driven › Day timer, and pick an early hour like 7 to 8 am.

/**
 * Daily follow-up digest from a "Follow-ups" tab.
 * Headers used (any order): Name, Next follow-up, Sent log.
 * Optional: Company, Owner email, Stage, Notes.
 * Each owner gets one email listing follow-ups due today, plus one
 * nudge for any still not updated 3 days later. Blank owner = you.
 * The people you follow up with are never emailed by this script.
 */
const SHEET_NAME  = 'Follow-ups';
const DEFAULT_TO  = '';             // e.g. 'sales@yourcompany.com' (blank = you)
const NUDGE_AFTER = 3;              // days overdue before a second reminder
const STOP_STAGE  = /^(won|lost|closed|not interested|do not contact)$/i;
const MAX_EMAILS  = 20;             // safety cap per run

function sendFollowUpDigest(now) {
  const sh   = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
  const rows = sh.getDataRange().getValues();
  const head = rows[0].map((h) => String(h).trim());
  const col  = (name) => head.indexOf(name);
  const c = {
    name: col('Name'), company: col('Company'), next: col('Next follow-up'),
    owner: col('Owner email'), stage: col('Stage'), notes: col('Notes'),
    log: col('Sent log'),
  };
  if (c.name === -1 || c.next === -1 || c.log === -1) {
    throw new Error('Need Name, Next follow-up and Sent log columns.');
  }
  const tz    = Session.getScriptTimeZone();
  // A time-driven trigger passes an event object, so only use a real Date.
  const today = now instanceof Date ? new Date(now) : new Date();
  today.setHours(0, 0, 0, 0);
  const get    = (row, i) => (i === -1 ? '' : row[i]);
  const isDate = (v) => v instanceof Date && !isNaN(v);
  const ymd    = (d) => Utilities.formatDate(d, tz, 'yyyy-MM-dd');
  const nice   = (d) => Utilities.formatDate(d, tz, 'EEE, MMM d');
  const me = Session.getEffectiveUser().getEmail();
  const byOwner = {};

  for (let r = 1; r < rows.length; r++) {
    const row  = rows[r];
    const next = row[c.next];
    if (!row[c.name] || !isDate(next)) continue;
    if (STOP_STAGE.test(String(get(row, c.stage)).trim())) continue;
    const d = new Date(next); d.setHours(0, 0, 0, 0);
    const late = Math.round((today - d) / 86400000);   // days overdue
    if (late < 0) continue;                             // not due yet
    // One reminder on the due day, one nudge if still overdue later.
    // A late start sends only the one that fits, never both.
    const step = late >= NUDGE_AFTER ? NUDGE_AFTER : 0;
    const sent = String(row[c.log] || '').split(';').filter(String);
    const key  = `F${ymd(d)}+${step}`;
    if (sent.includes(key)) continue;
    if (step === 0 && sent.includes(`F${ymd(d)}+${NUDGE_AFTER}`)) continue;
    const who  = [row[c.name], get(row, c.company)].filter(String).join(', ');
    const note = String(get(row, c.notes)).trim();
    const due  = late === 0 ? 'due today' : `overdue ${late} day${late > 1 ? 's' : ''} (was ${nice(d)})`;
    const to = String(get(row, c.owner)).trim() || DEFAULT_TO || me;
    (byOwner[to] = byOwner[to] || []).push({
      late, text: `${who}: ${due}${note ? ` - ${note.slice(0, 120)}` : ''}`,
      r: r + 1, log: sent.concat(key).slice(-9).join(';'),
    });
  }

  let emails = 0;
  for (const to of Object.keys(byOwner)) {
    if (emails >= MAX_EMAILS || MailApp.getRemainingDailyQuota() < 1) break;
    const items = byOwner[to].sort((a, b) => b.late - a.late);
    MailApp.sendEmail(to, `Follow-ups for ${nice(today)} (${items.length})`,
      'Who to get back to (oldest first). After you reach out, set a new ' +
      'Next follow-up date or change the Stage:\n\n- ' +
      items.map((x) => x.text).join('\n- ') + '\n\nSent from the Follow-ups sheet.');
    // Log only after the email for this owner went out.
    items.forEach((x) => sh.getRange(x.r, c.log + 1).setValue(x.log));
    emails++;
  }
}

How it behaves:

We mock-tested this logic before publishing: the first run sent one list per owner in the right order with notes, a rerun the same day and the next day sent nothing, a row still untouched three days later got exactly one nudge, a trigger that started five days late sent one nudge instead of two emails, a new follow-up date got its own reminder, closed stages and future dates were skipped, and nothing was logged when the email quota was used up.

Tips

Want reminders without maintaining a script? SheetReminders (disclosure: we make it) is a Google Sheets add-on that watches a date column and emails the right person on time. Point it at the Next follow-up column from Step 2: reminders on the day or after a date for overdue follow-ups, personalized messages with {{Column name}} and {{Days left}}, a digest mode with one email per person, skipping rows marked Done, and a sent log written back to the row. Sheet data stays inside your Google account and it uses narrow scopes. 14-day free trial, no credit card required. It is live on the Google Workspace Marketplace.
See SheetReminders

Related Google Sheets guides

Instructions refer to Google Sheets and Apps Script as they worked on November 13, 2026. Not affiliated with Google or any other vendor named here. Google Sheets and Google Drive are trademarks of Google LLC. Features change, so verify in your own account. Questions or corrections: hello@loomwise.app.