Smart Life US

← All posts · 2026-08-30 · Excel & Automation

Google Sheets Late-Arrival Alert Auto Email: Inbound KPI 5

Google Sheets Late-Arrival Alert Auto Email: Inbound KPI 5

If an inbound truck is late and nobody notices, the on-site plan is immediately derailed. This post explains how to set up Google Sheets late-arrival alert auto emails, along with an email automation that prepares and sends a summary of tomorrow’s inbound appointments. Once you wire up late-arrival alert emails and a “tomorrow’s appointments” preview using just Google Sheets and Apps Script, the manager can glance at their inbox and instantly see which time windows require action.

Late-arrival alert automation setup

This is Part 5 of a KPI/reporting series based on a Google Sheets inbound appointment system that is actually in use at a logistics center. In the previous four posts, we covered how to calculate daily KPIs, build nightly triggers to store history, score carriers, and send weekly reports by email automatically. In this part, we extend that setup to automatically notify you of “appointments that are problematic right now” and “appointments you need to prepare for tomorrow.”


Why late-arrival alerts and tomorrow previews matter

Once you start using an appointment system, there are moments when KPIs alone don’t directly help same-day operations. KPIs mostly tell you the results “up to yesterday.” On the floor, what really matters is which trucks are late as of now, and in which time slots tomorrow’s volume will be concentrated.

At the logistics center I manage, we initially focused only on on-time rate and average dwell time. But even a small slippage in inbound appointments would throw dock and labor schedules off all at once, and the manager usually found out too late to do anything meaningful. That’s why we needed a flow that sends an alert as close as possible to “the moment a delay becomes a real issue,” and that also summarizes tomorrow’s appointments in advance.

The key is not just coloring appointments as “on-time/late,” but comparing actual check-in records with scheduled times and picking out only those that require action at this moment. The code in this post is designed to do exactly that. As long as you adapt the check-in detection part to your own warehouse’s logs, you can apply everything else as-is.


Basic rules and sheet structure for alerts

In this part we reuse the sheets and functions already built in the previous posts, such as Google Sheets Weekly Report Auto Email: KPI Part 4, with Final Code (Post-Review Version). In particular, we use the main appointment sheet APPT_MAIN, the KPI history sheet KPI_HISTORY, and the KPI_RECIPIENTS sheet that lists alert recipients. The sheet structure is already fixed at the series level, so we simply assume that structure here.

When implementing late-arrival alerts and tomorrow’s appointment preview, we’ll follow these basic rules:

  1. Scheduled time vs. arrival detection

The scheduled time comes from the date/time columns in APPT_MAIN. Arrival detection uses the check-in logs you’re already recording. We add a helper function that answers “Was this appointment actually checked in?” In this post we only stub out a placeholder, KPI_isApptCheckedIn_(apptId); you’ll implement the real logic to match your check-in log structure.

  1. Explicit grace period

Taking traffic and other factors into account, we don’t treat an appointment as late the moment its scheduled time passes. Instead, we add a configurable grace period (30 minutes in the example), and only treat an appointment as late if scheduled time + grace period < now. In the code, this value lives as a constant; you can later change it to be read from a settings sheet.

  1. No repeat alerts for the same appointment

We dedicate one spare column in APPT_MAIN as a “late-alert sent” flag. When an email for that appointment is sent successfully, we store Y there. When we search for late appointments, we only consider rows where this flag is blank, ensuring that the same appointment doesn’t trigger multiple alert emails.

  1. One grouped email, not one email per appointment

Even if multiple trucks are late, we send a single email listing all late appointments in time order. Likewise, tomorrow’s preview is a single email summarizing all of tomorrow’s appointments. If emails arrive too frequently, people on the floor will ignore them; the goal is “one concise digest when it’s needed”, not a flood of messages.


Step 1 — Define configuration constants for alerts

First, we define a configuration object just for this part. Following the series convention, we use the KPI_ prefix and avoid colliding with any existing CONFIG-style constants. It will hold values like grace period length, sheet names, email subjects, and flag column indexes.

  1. What this code does
  • Centralizes all alert-related constants: timezone, sheet names, grace period, email subjects, and indicator column numbers.
  1. Where to paste it
  • In Google Sheets: Extensions → Apps Script → in the existing KPI project’s Code.gs, near the top, right below your other KPI_ constants.
  1. What to do after pasting
  • Save (⌘S / CTRL+S). No need to run anything yet; later functions will reference these constants.
Apps Script (JavaScript)
const KPI_ALERT_CONFIG = {                             // → Settings dedicated to KPI alerts
  TIMEZONE: 'America/New_York',                        // → Common system timezone
  LATE_GRACE_MINUTES: 30,                              // → Grace period (minutes) before treating as late
  MAIN_SHEET_NAME: 'APPT_MAIN',                        // → Main appointment sheet
  RECIPIENT_SHEET_NAME: 'KPI_RECIPIENTS',              // → Recipient list sheet
  LATE_ALERT_SUBJECT: '[Inbound] Late Appointment Alert',        // → Subject line for late-alert emails
  TOMORROW_PREVIEW_SUBJECT: '[Inbound] Tomorrow Appointment Preview', // → Subject line for preview emails
  LATE_ALERT_SENT_COLUMN: 14,                          // → Column index in APPT_MAIN for "late alert sent" flag (example)
  ID_COLUMN: 13                                        // → APPT_MAIN appointment ID column index (fixed: column M)
};                                                     //

How to check it: if there’s no red error marker on this constant definition in the Apps Script editor, it’s valid. Also confirm that column 14 in APPT_MAIN is actually free to use as the late-alert flag.


Step 2 — Load recipients and add a common email helper

Both late-arrival alerts and tomorrow’s preview emails go to the people listed in KPI_RECIPIENTS. We assume this sheet has at least three columns: email, name, and active flag.

  1. What this code does
  • Reads email addresses and active status from KPI_RECIPIENTS and returns an array of valid recipients.
  • Wraps Gmail sending in a helper so you can send an email with just a subject and body.
  1. Where to paste it
  • Directly below KPI_ALERT_CONFIG.
  1. What to do after pasting
  • Later, use a small test function to call KPI_loadAlertRecipients_() and inspect the log result.
Apps Script (JavaScript)
function KPI_loadAlertRecipients_() {                         // → Load alert recipients
  const ss = SpreadsheetApp.getActive();                     // → Current spreadsheet
  const sheet = ss.getSheetByName(KPI_ALERT_CONFIG.RECIPIENT_SHEET_NAME); // → Recipient sheet
  if (!sheet) {                                              // → Sheet missing
    throw new Error('KPI_RECIPIENTS sheet not found.');      // → Throw error
  }                                                          
  const lastRow = sheet.getLastRow();                        // → Last row
  if (lastRow < 2) {                                         // → No data
    return [];                                               // → Return empty array
  }                                                          
  const range = sheet.getRange(2, 1, lastRow - 1, 3);        // → Data range (excluding header)
  const values = range.getValues();                          // → Read values
  const recipients = [];                                     // → Result array
  values.forEach((row, idx) => {                             // → Iterate rows
    const email = String(row[0] || '').trim();               // → Email
    const name = String(row[1] || '').trim();                // → Name (optional)
    const active = String(row[2] || '').trim().toUpperCase();// → Active flag
    if (!email) {                                            // → No email
      return;                                                // → Skip
    }                                                        
    if (active !== 'Y' && active !== 'YES' && active !== '1') { // → Inactive
      return;                                                // → Skip
    }                                                        
    recipients.push({ email, name: name || email });         // → Add recipient
  });                                                        
  return recipients;                                         // → Return array
}                                                            

function KPI_sendEmail_(subject, body) {                     // → Email sending helper
  const recipients = KPI_loadAlertRecipients_();             // → Load recipients
  if (!recipients.length) {                                  // → No one to send to
    return { sent: 0, message: 'No active alert recipients.' };  // → Return status
  }                                                          
  const toList = recipients.map(r => r.email).join(',');     // → Address list
  GmailApp.sendEmail(toList, subject, body, {                 // → Send email
    name: 'Inbound Appointment Alert Bot'                    // → Sender name
  });                                                        
  return { sent: recipients.length, message: 'OK' };         // → Send result
}                                                            

How to check it: put a test email and Y in KPI_RECIPIENTS, then in a test function call KPI_loadAlertRecipients_() and check that the returned array has length ≥ 1 in the logs.


Step 3 — Core logic to find late appointments

Now we need a function that picks out “appointments which, as of now, are late, have no check-in record, and have not yet been flagged as alerted.” This function is the core logic used by the main late-alert sender.

In a real system you’ll typically have a CHECKIN_LOG or similar sheet and you’ll look up the appointment ID to determine if it has checked in. Here we leave a placeholder KPI_isApptCheckedIn_(apptId) that always returns false as a stub; you’ll replace this with your own logic.

  1. What this code does
  • Reads date/time, appointment ID, and late-alert flag from APPT_MAIN.
  • Returns an array of rows where scheduled + grace < now, not checked in, and flag is blank.
  1. Where to paste it
  • Directly below the code from the previous step.
  1. What to do after pasting
  • Later, run a test function KPI_testFindLateAppts_() and inspect the log output.
Apps Script (JavaScript)
function KPI_isApptCheckedIn_(apptId) {                       // → Determine if appointment is checked in (example)
  if (!apptId) {                                              // → No appointment ID
    return false;                                             // → Treat as not checked in
  }                                                           
  // In the real implementation, look up the appointment ID in CHECKIN_LOG or similar.  // → Needs log lookup
  return false;                                               // → Example: always treated as not checked in
}                                                            

function KPI_findLateAppts_() {                               // → Get list of late appointments
  const ss = SpreadsheetApp.getActive();                      // → Current workbook
  const sheet = ss.getSheetByName(KPI_ALERT_CONFIG.MAIN_SHEET_NAME); // → Appointment sheet
  if (!sheet) {                                               // → Sheet missing
    throw new Error('APPT_MAIN sheet not found.');            // → Error
  }                                                           
  const lastRow = sheet.getLastRow();                         // → Last row
  if (lastRow < 2) {                                          // → No data
    return [];                                                // → Empty list
  }                                                           
  const now = new Date();                                     // → Current time
  const values = sheet.getRange(2, 1, lastRow - 1, KPI_ALERT_CONFIG.LATE_ALERT_SENT_COLUMN).getValues(); // → Data
  const lateAppts = [];                                       // → Result array

  values.forEach((row, idx) => {                              // → Iterate rows
    const rowIndex = idx + 2;                                 // → Actual row index
    const dateVal = row[0];                                   // → A: Date
    const timeVal = row[1];                                   // → B: Start time
    const type = String(row[2] || '').trim();                 // → C: Equipment type
    const door = String(row[3] || '').trim();                 // → D: Door
    const container = String(row[4] || '').trim();            // → E: Container
    const carrier = String(row[5] || '').trim();              // → F: Carrier
    const client = String(row[6] || '').trim();               // → G: Customer
    const remark = String(row[7] || '').trim();               // → H: Remark
    const endTimeVal = row[8];                                // → I: End time
    const createdAt = row[9];                                 // → J: Created timestamp
    const qty = row[10];                                      // → K: Quantity
    const pallet = row[11];                                   // → L: Pallet
    const apptId = String(row[12] || '').trim();              // → M: Appointment ID
    const alertSentFlag = row[13];                            // → N (example): Late-alert flag

    if (!dateVal || !timeVal || !apptId) {                    // → Missing required values
      return;                                                 // → Skip
    }                                                         
    if (alertSentFlag === 'Y') {                              // → Already alerted
      return;                                                 // → Skip
    }                                                         
    if (KPI_isApptCheckedIn_(apptId)) {                       // → Already checked in
      return;                                                 // → Skip
    }                                                         
    const dateObj = new Date(dateVal);                        // → Date object
    const timeObj = new Date(timeVal);                        // → Time object
    if (isNaN(dateObj.getTime()) || isNaN(timeObj.getTime())) { // → Invalid values
      return;                                                 // → Skip
    }                                                         
    const scheduled = new Date(dateObj);                      // → Scheduled datetime
    scheduled.setHours(timeObj.getHours(), timeObj.getMinutes(), 0, 0); // → Apply time
    const graceMillis = KPI_ALERT_CONFIG.LATE_GRACE_MINUTES * 60 * 1000; // → Grace in ms
    const deadline = new Date(scheduled.getTime() + graceMillis); // → Late threshold
    if (deadline.getTime() >= now.getTime()) {                // → Still within grace
      return;                                                 // → Skip
    }                                                         

    lateAppts.push({                                          // → Add late appointment
      rowIndex,
      apptId,
      date: dateObj,
      time: timeObj,
      type,
      door,
      container,
      carrier,
      client,
      remark,
      qty,
      pallet
    });                                                       
  });                                                         

  return lateAppts;                                           // → Return list of late appointments
}                                                             

How to check it: add a test row in APPT_MAIN with a scheduled time 1–2 hours in the past, then run KPI_testFindLateAppts_() (a small wrapper you write) and confirm in the logs that at least one late appointment is detected.


Step 4 — Build and send the late-arrival alert email

Now we implement sendLateAlerts(), the function that actually sends the late-arrival email. It gathers all late appointments, sends a single grouped email, and on success, flags each corresponding row as “alert sent.” We also use LockService to prevent duplicate sends if multiple users trigger this at the same time.

  1. What this code does
  • Sorts late appointments by time, builds a text-table email body, and sends a single email.
  • Marks the flag column with Y only if the email send succeeds.
  1. Where to paste it
  • Directly below KPI_findLateAppts_().
  1. What to do after pasting
  • Write and run a KPI_testSendLateAlerts_() test function that calls sendLateAlerts() and inspects the result.
Apps Script (JavaScript)
function sendLateAlerts() {                                  // → Main function for late-arrival alerts
  const lock = LockService.getScriptLock();                  // → Script-wide lock
  lock.waitLock(30000);                                      // → Wait up to 30 seconds
  try {                                                      // → Execute under lock
    const lateAppts = KPI_findLateAppts_();                  // → Fetch late appointments
    if (!lateAppts.length) {                                 // → None found
      return { sent: 0, message: 'No late appointments.' };  // → Exit
    }                                                        

    lateAppts.sort((a, b) => {                               // → Sort by time
      const t1 = a.date.getTime() +                          // → Base on a’s date
        (a.time.getHours() * 60 + a.time.getMinutes()) * 60000; // → Add time in minutes
      const t2 = b.date.getTime() +                          // → Base on b’s date
        (b.time.getHours() * 60 + b.time.getMinutes()) * 60000; //
      return t1 - t2;                                        // → Ascending
    });                                                      

    let bodyLines = [];                                      // → Email body lines
    bodyLines.push('The following inbound appointments have exceeded the grace period and have not yet arrived.'); // → Intro text
    bodyLines.push('');                                      // → Blank line
    bodyLines.push('Date\tTime\tCarrier\tContainer\tDoor\tCustomer\tType\tQty\tPallets\tRemark'); // → Header
    lateAppts.forEach(item => {                              // → Add each appointment row
      const y = Utilities.formatDate(item.date, KPI_ALERT_CONFIG.TIMEZONE, 'yyyy-MM-dd'); // → Date string
      const hm = Utilities.formatDate(item.time, KPI_ALERT_CONFIG.TIMEZONE, 'HH:mm');     // → Time string
      bodyLines.push([
        y,
        hm,
        item.carrier || '',
        item.container || '',
        item.door || '',
        item.client || '',
        item.type || '',
        item.qty || '',
        item.pallet || '',
        item.remark || ''
      ].join('\t'));                                         // → Tab-separated
    });                                                      
    bodyLines.push('');                                      // → Blank line
    bodyLines.push('※ This email was sent by the Google Sheets inbound appointment KPI automation.'); // → Footer

    const subject = KPI_ALERT_CONFIG.LATE_ALERT_SUBJECT;     // → Email subject
    const body = bodyLines.join('\n');                       // → Body as string
    const sendResult = KPI_sendEmail_(subject, body);        // → Send email

    if (sendResult.sent > 0) {                               // → If send succeeded
      const ss = SpreadsheetApp.getActive();                 // → Current spreadsheet
      const sheet = ss.getSheetByName(KPI_ALERT_CONFIG.MAIN_SHEET_NAME); // → Appointment sheet
      lateAppts.forEach(item => {                            // → Mark each appointment
        sheet.getRange(item.rowIndex, KPI_ALERT_CONFIG.LATE_ALERT_SENT_COLUMN).setValue('Y'); // → Set alert flag
      });                                                    
    }                                                        

    return {                                                 // → Return summary
      sent: sendResult.sent,
      lateCount: lateAppts.length,
      message: sendResult.message
    };                                                       
  } finally {                                                
    lock.releaseLock();                                      // → Release lock
  }                                                          
}                                                             

How to check it: create a test appointment in the past, run sendLateAlerts(), and confirm that (1) you receive a single email, (2) the late-alert flag column for that row is set to Y, and (3) running sendLateAlerts() again with the same data sends no additional email.


Step 5 — Automate tomorrow’s appointment preview email

If late-arrival alerts tell you “which appointments are currently problematic,” tomorrow’s preview gives you “which appointments you need to prepare for tomorrow.” This function filters APPT_MAIN for appointments on tomorrow’s date, sorts them by time, and builds a compact list including carrier, container, and door.

  1. What this code does
  • Filters for appointments on tomorrow’s date, sorts them by time, and sends a single grouped email.
  • Sends nothing if there are no appointments tomorrow.
  1. Where to paste it
  • Directly below sendLateAlerts().
  1. What to do after pasting
  • Add a couple of test rows for tomorrow in APPT_MAIN, run sendTomorrowPreview(), and check the resulting email.
Apps Script (JavaScript)
function sendTomorrowPreview() {                             // → Main function for tomorrow’s appointment preview
  const ss = SpreadsheetApp.getActive();                     // → Current spreadsheet
  const sheet = ss.getSheetByName(KPI_ALERT_CONFIG.MAIN_SHEET_NAME); // → Appointment sheet
  if (!sheet) {                                              // → Missing sheet
    throw new Error('APPT_MAIN sheet not found.');           // → Error
  }                                                          
  const lastRow = sheet.getLastRow();                        // → Last row
  if (lastRow < 2) {                                         // → No data
    return { sent: 0, message: 'No appointment data.' };     // → Exit
  }                                                          

  const now = new Date();                                    // → Current time
  const tz = KPI_ALERT_CONFIG.TIMEZONE;                      // → Timezone
  const tomorrow = new Date(now);                            // → Copy today
  tomorrow.setDate(tomorrow.getDate() + 1);                  // → Move to tomorrow
  const tomorrowYmd = Utilities.formatDate(tomorrow, tz, 'yyyy-MM-dd'); // → Tomorrow as yyyy-MM-dd

  const values = sheet.getRange(2, 1, lastRow - 1, KPI_ALERT_CONFIG.ID_COLUMN).getValues(); // → A..M (date..ID)
  const appts = [];                                          // → Tomorrow appointments

  values.forEach((row, idx) => {                             // → Iterate rows
    const rowIndex = idx + 2;                                // → Actual row index
    const dateVal = row[0];                                  // → A: Date
    const timeVal = row[1];                                  // → B: Start time
    const type = String(row[2] || '').trim();                // → C: Equipment type
    const door = String(row[3] || '').trim();                // → D: Door
    const container = String(row[4] || '').trim();           // → E: Container
    const carrier = String(row[5] || '').trim();             // → F: Carrier
    const client = String(row[6] || '').trim();              // → G: Customer
    const remark = String(row[7] || '').trim();              // → H: Remark
    const apptId = String(row[12] || '').trim();             // → M: Appointment ID

    if (!dateVal || !timeVal || !apptId) {                   // → Missing required values
      return;                                                // → Skip
    }                                                        
    const dateObj = new Date(dateVal);                       // → Date object
    if (isNaN(dateObj.getTime())) {                          // → Invalid date
      return;                                                // → Skip
    }                                                        
    const ymd = Utilities.formatDate(dateObj, tz, 'yyyy-MM-dd'); // → yyyy-MM-dd string
    if (ymd !== tomorrowYmd) {                               // → Not tomorrow
      return;                                                // → Skip
    }                                                        
    const timeObj = new Date(timeVal);                       // → Time object
    if (isNaN(timeObj.getTime())) {                          // → Invalid time
      return;                                                // → Skip
    }                                                        

    appts.push({                                             // → Add tomorrow appointment
      rowIndex,
      apptId,
      date: dateObj,
      time: timeObj,
      type,
      door,
      container,
      carrier,
      client,
      remark
    });                                                      
  });                                                         

  if (!appts.length) {                                       // → No appointments tomorrow
    return { sent: 0, message: 'No appointments tomorrow.' };   // → Exit
  }                                                          

  appts.sort((a, b) => {                                     // → Sort by time
    const t1 = a.time.getHours() * 60 + a.time.getMinutes(); // → a in minutes
    const t2 = b.time.getHours() * 60 + b.time.getMinutes(); // → b in minutes
    return t1 - t2;                                          // → Ascending
  });                                                        

  let bodyLines = [];                                        // → Email body lines
  bodyLines.push('Here is tomorrow’s inbound appointment schedule, sorted by time.'); // → Intro
  bodyLines.push('');                                        // → Blank line
  bodyLines.push('Time\tCarrier\tContainer\tDoor\tCustomer\tType\tRemark'); // → Header
  appts.forEach(item => {                                    // → Add each row
    const hm = Utilities.formatDate(item.time, tz, 'HH:mm'); // → Time string
    bodyLines.push([
      hm,
      item.carrier || '',
      item.container || '',
      item.door || '',
      item.client || '',
      item.type || '',
      item.remark || ''
    ].join('\t'));                                           // → Tab-separated
  });                                                        
  bodyLines.push('');                                        // → Blank line
  bodyLines.push('※ This email was sent by the Google Sheets inbound appointment KPI automation.'); // → Footer

  const subject = KPI_ALERT_CONFIG.TOMORROW_PREVIEW_SUBJECT; // → Subject
  const body = bodyLines.join('\n');                         // → Body
  const sendResult = KPI_sendEmail_(subject, body);          // → Send email

  return {                                                   // → Return summary
    sent: sendResult.sent,
    apptCount: appts.length,
    message: sendResult.message
  };                                                         
}                                                             

How to check it: create a couple of test appointments dated for tomorrow, run sendTomorrowPreview(), and confirm that you receive a single email listing tomorrow’s appointments by time. If there are no appointments tomorrow, no email should be sent.


Practical tips: triggers, testing, and day-to-day use

The code is ready, but production use depends heavily on how you set triggers and test. Based on real-world operation experience, consider these points:

  1. Be intentional about trigger frequency and timing.
  • Late alerts: if you trigger sendLateAlerts() too frequently, you get noise; too rarely, you find out too late. A 30-minute or 1-hour interval worked well in practice.
  • Tomorrow preview: once a day is enough. Schedule sendTomorrowPreview() to run just before headcount or dock-planning meetings (for example, at 3:00 PM).
  1. Tune LATE_GRACE_MINUTES to your operation.

Start with 30 minutes, then adjust for specific routes/customers as you observe patterns. If certain lanes constantly trigger “false alarms,” you might either extend the grace period for that lane or renegotiate the planned time slot.

  1. Keep the recipient list focused.

Rather than listing everyone in KPI_RECIPIENTS, keep it to people who can actually act on the information: dock assignment, gate operations, and labor scheduling. If needed, they can re-share the content in team channels. The more people receive an email, the more likely it is to be ignored.

  1. Always test with minimal data first.
  • For late alerts: create one or two test appointments 1–2 hours in the past, set yourself as the sole active recipient, and manually run sendLateAlerts().
  • For tomorrow’s preview: create two or three test appointments for tomorrow and manually run sendTomorrowPreview(). Only after validating the format and behavior should you attach time-based triggers.

Once these pieces are in place, and combined with the earlier parts of the series (daily KPIs, history, carrier reports, weekly summaries), you’ll have a complete inbound appointment alert automation in Google Sheets. KPIs cover “results up to yesterday,” while late alerts and tomorrow previews cover “what to act on now and tomorrow.” That way, you can prioritize operations from your inbox and KPI dashboard, without constantly opening and searching through the raw sheets.


Conclusion

Late-arrival alerts and tomorrow’s appointment preview are essentially the final pieces of the inbound appointment KPI automation puzzle. With just Google Sheets and Apps Script you can implement both automatic late-arrival alert emails and tomorrow’s appointment preview emails, so that managers can see today’s and tomorrow’s risk windows straight from their inbox instead of hunting through spreadsheets.

If you want to try this right away, do this one thing today:

Add your own email address as a row in KPI_RECIPIENTS, paste in the KPI_ALERT_CONFIG, KPI_loadAlertRecipients_(), KPI_findLateAppts_(), and sendLateAlerts() code from this post, then create a single test appointment in the past and run sendLateAlerts() manually.

Once you experience that first real alert email coming in, tuning the grace period and adding time-based triggers will feel much more straightforward.