Smart Life US

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

Google Sheets reservation KPI aggregation: on-time rate & dwell time auto-calculation

Google Sheets reservation KPI aggregation: on-time rate & dwell time auto-calculation

Introduction: why you need a reservation KPI aggregation method in Google Sheets

Once you start managing inbound appointments in Google Sheets, you eventually get to this question:

“I want to see, by carrier, what the on-time arrival rate looks like, what the average dwell time is, and I want that report to update every day automatically.”

KPI auto-aggregation flow

This post is a guide to a Google Sheets reservation KPI aggregation method for operators with that kind of need. The goal is to auto-calculate on-time rate and dwell time just by pasting an Apps Script snippet, without touching formulas on the sheet.

This is the first KPI part on top of the inbound appointment & check-in series. In earlier posts we already built the appointment sheet (APPT_MAIN), wired up appointment creation, check-in, status changes, and even a “today’s status” dashboard. Now we’re going to layer a “on-time rate & dwell time” KPI layer on top of that. In this part we’ll complete the core functions and sheet structure to calculate daily KPIs; in the following parts we’ll extend it into carrier- and customer-level reports and dashboards.

Even when reservation and check-in are automated, if you can’t see “how well it’s being run” numerically, decision-making in operations stays hard. With the same underlying data, if you add Apps Script-based automation for Google Sheets on-time calculations and dwell-time aggregation, you can quickly see where you stand without manually building the same report every single day.


KPI design: decide what to calculate and how before coding

Before writing any code, you need to make a clear decision: “What exactly are we going to treat as KPIs?” Here we focus on two metrics.

1. On-time arrival rate

The denominator for on-time rate is “number of reservations where an actual arrival was recorded.” Reservations where the truck never came are not included in the denominator. The numerator is the count of reservations where the absolute difference between planned appointment time and actual arrival time is within a configured threshold (e.g., ±15 minutes).

In short:

  • Denominator: count of reservations that have an arrival time recorded
  • Numerator: among those, count of on-time arrivals
  • Example on-time threshold: |arrival - appointment| ≤ 15 minutes
  • Formula:

On-time rate(%) = (On-time arrival count ÷ Arrivals with record) × 100

2. Average dwell time

Dwell time is the time from when the vehicle checks in at the site to completion/check-out. Only rows where both arrival and completion times are recorded are included; in-progress reservations are excluded. We calculate in minutes, and later you can adjust formatting on the sheet to show as hours/minutes if desired.

3. Clearly define what to exclude from calculation

For on-time rate and dwell time, being explicit about what is in and what is out is more important than the exact formula. For example, we exclude the following rows from KPI calculations:

  • Reservations without any arrival record
  • Reservations with an arrival but no completion time (still in process)
  • Rows where the date/time cells contain text (e.g., “delayed”, “on hold”)

If you treat these as 0 and include them in averages, your KPIs can look better than reality. The computeKpiForDate_() function in this post converts date/time values into Date and numbers and only includes finite numbers in the aggregation. Values that fail the Number.isFinite() check are counted as skipped and summarized in Apps Script logs by date and count. This makes it much easier to trace “where things fell out” when the denominator is smaller than expected.


KPI sheet structure: designing KPI_DAILY with one row per day

Ideally, appointments and KPIs should be separated. In this series we assume appointment & check-in data are already being logged in APPT_MAIN, and we introduce a KPI-only sheet called KPI_DAILY.

A recommended structure is:

  • Column A: KPI date (yyyy-MM-dd string)
  • Column B: total reservation count for that date
  • Column C: count of reservations with an arrival record
  • Column D: count of on-time arrivals
  • Column E: on-time rate (%)
  • Column F: average dwell time (minutes)
  • Column G: count of reservations excluded from calculation (skipped)
  • Column H: timestamp when KPI was computed

The pattern is “append one row per day.” Even if you recalculate the same date many times, the code finds that date’s row and overwrites it. That means you can change KPI formulas or thresholds and recalculate without fear. The raw appointment data in APPT_MAIN remains untouched, and KPIs are stored separately, effectively separating operational data from reporting data.

The saveDailyKpi_() function in this post always looks up KPI_DAILY by KPI date; if a row exists, it updates; if not, it appends. With this in place, you can later build weekly/monthly reports or link into views like “Inbound appointment dashboard, part 1” without changing the structure.


Apps Script flow: procedure for calculating on-time rate and dwell time

The Apps Script we implement in this post flows as follows. The core is computeKpiForDate_(dateObj); the other functions support it and provide a simple test hook.

  1. Input validation
  • Confirm that the argument is a valid Date object.
  • If not valid, throw an error so bad calls don’t quietly generate wrong KPIs.
  1. Build target date string
  • Time zone is always fixed at 'America/New_York'.
  • Use Utilities.formatDate() to generate a yyyy-MM-dd string, and filter APPT_MAIN rows with that date.
  1. Read appointment data
  • Read all rows from row 2 down to the last row from APPT_MAIN in one shot.
  • Define column indexes for appointment date, appointment time, check-in, and check-out as constants that match your sheet.
  1. Row-level calculation
  • Count only rows whose appointment date matches the target date as total.
  • Only rows with both appointment time and arrival time calculate on-time vs. late.
  • Only rows with both arrival and completion time calculate dwell time (minutes).
  • If any date/time is invalid or the converted minutes are not numeric, mark that row as skipped.
  1. Aggregate values
  • If arrival count (arrived) is 0, set on-time rate to 0 to avoid NaN.
  • If dwell time count (dwellCount) is 0, set average dwell time to 0.
  1. Logging and saving
  • Log a summary with Logger.log() for debugging.
  • Pass the result object into saveDailyKpi_() to upsert (update or insert) into KPI_DAILY.
  • Return the result object so test functions can inspect it.

We’ll now look at the code based on this structure.


Step 1 — Define KPI config constants and sheet structure

First, define the configuration and column index constants used only for this KPI feature. To avoid any conflict with constants from earlier posts, we prefix them all with KPI_.

1) What this code does

  • Defines constants for sheet names, column indexes, time zone, and on-time threshold (minutes) needed for KPI calculations.

2) Where to paste

  • In Google Sheets, go to Extensions → Apps Script → Code.gs, and paste this at the top (above or below other series constants, as long as the names do not clash).

3) What to do after pasting

  • Hit Save (⌘S or Ctrl+S). There’s no function to run in this step.
Apps Script (JavaScript)
const KPI_CONFIG = {                              // → Config set for KPI
  TZ: 'America/New_York',                        // → Fixed time zone
  APPT_SHEET_NAME: 'APPT_MAIN',                  // → Appointment source sheet name
  KPI_SHEET_NAME: 'KPI_DAILY',                   // → Daily KPI sheet name
  ONTIME_MINUTES_THRESHOLD: 15                   // → On-time decision threshold (minutes)
};                                               

// Change only these to match your sheet structure
const KPI_COL_APPT_DATE = 1;                     // → APPT_MAIN: appointment date (column A)
const KPI_COL_APPT_TIME = 2;                     // → APPT_MAIN: appointment start time (column B)
// Arrival/completion times depend on your check-in/checkout implementation
// If you haven't added check-in/checkout columns to APPT_MAIN yet, leave these as 0,
// and once you add the columns, update them to the actual column numbers (1-based).
const KPI_COL_CHECKIN_AT = 0;                    // → Arrival time column index (0 if none)
const KPI_COL_CHECKOUT_AT = 0;                   // → Completion time column index (0 if none)

const KPI_COL_KPI_DATE = 1;                      // → KPI_DAILY: KPI date (column A)
const KPI_COL_KPI_TOTAL = 2;                     // → KPI_DAILY: total appointment count (column B)
const KPI_COL_KPI_ARRIVED = 3;                   // → KPI_DAILY: count with arrival recorded (column C)
const KPI_COL_KPI_ONTIME = 4;                    // → KPI_DAILY: on-time arrival count (column D)
const KPI_COL_KPI_ONTIME_RATE = 5;               // → KPI_DAILY: on-time rate % (column E)
const KPI_COL_KPI_AVG_DWELL = 6;                 // → KPI_DAILY: average dwell time in minutes (column F)
const KPI_COL_KPI_SKIPPED = 7;                   // → KPI_DAILY: excluded (skipped) count (column G)
const KPI_COL_KPI_CALC_AT = 8;                   // → KPI_DAILY: calculation timestamp (column H)

If KPI_COL_CHECKIN_AT and KPI_COL_CHECKOUT_AT are left as 0, the code in this post will safely skip arrival and dwell-time calculations. Once your check-in/checkout sheet structure is finalized in a later part of the series, you can simply fill in the actual column numbers here and reuse the exact same KPI code.


Step 2 — Implement the core per-date KPI calculation function

Next is the heart of this post: computeKpiForDate_(). It reads appointments for a given date, calculates on-time rate and average dwell time, and saves the results into KPI_DAILY. It includes input validation, numeric validation, division-by-zero handling, and logging.

1) What this code does

  • For the specified date (Date object), reads appointment data, calculates on-time arrival rate and average dwell time, and writes results to the KPI_DAILY sheet.

2) Where to paste

  • Paste this right below the constants you just defined, at the same level as your other functions.

3) What to do after pasting

  • You’ll test-run it with the KPI_testComputeToday_() function in Step 4.
Apps Script (JavaScript)
function computeKpiForDate_(dateObj) {                          // → Calculate KPI for a specific date
  if (!(dateObj instanceof Date) || isNaN(dateObj.getTime())) { // → Check if input is a valid Date
    throw new Error('computeKpiForDate_: Invalid Date object.'); 
  }

  const ss = SpreadsheetApp.getActiveSpreadsheet();             // → Get the active spreadsheet
  const apptSheet = ss.getSheetByName(KPI_CONFIG.APPT_SHEET_NAME); // → Find appointment sheet
  if (!apptSheet) {                                             // → If not found
    throw new Error('Could not find appointment sheet (APPT_MAIN).'); 
  }

  const targetYmd = Utilities.formatDate(                       // → Format target date as yyyy-MM-dd
    dateObj,
    KPI_CONFIG.TZ,
    'yyyy-MM-dd'
  );                                                            

  const lastRow = apptSheet.getLastRow();                       // → Last row in appointment sheet
  if (lastRow < 2) {                                            // → Header only, no data
    const emptyResult = {                                       // → No data to compute KPI
      date: targetYmd,
      total: 0,
      arrived: 0,
      ontime: 0,
      ontimeRate: 0,
      avgDwellMinutes: 0,
      skipped: 0
    };
    saveDailyKpi_(emptyResult);                                 // → Write empty result to sheet
    Logger.log(JSON.stringify(emptyResult));                    // → Log result
    return emptyResult;                                         // → Return result
  }

  const lastCol = apptSheet.getLastColumn();                    // → Last column index
  const range = apptSheet.getRange(2, 1, lastRow - 1, lastCol); // → Data range (excluding header)
  const values = range.getValues();                             // → Read as 2D array

  let total = 0;                                                // → Appointment count on target date
  let arrived = 0;                                              // → Count with arrival record
  let ontime = 0;                                               // → On-time arrival count
  let dwellSum = 0;                                             // → Sum of dwell times (minutes)
  let dwellCount = 0;                                           // → Count of dwell-time records
  let skipped = 0;                                              // → Rows excluded from calculation

  const tz = KPI_CONFIG.TZ;                                     // → Use configured time zone
  const threshold = KPI_CONFIG.ONTIME_MINUTES_THRESHOLD;        // → On-time threshold (minutes)

  for (let i = 0; i < values.length; i++) {                     // → Iterate through each appointment row
    const row = values[i];                                      // → Current row data
    const apptDateCell = row[KPI_COL_APPT_DATE - 1];            // → Appointment date value

    if (!apptDateCell) {                                        // → Empty appointment date
      skipped++;                                                // → Increase skipped count
      continue;                                                 // → Next row
    }

    let apptYmd;                                                // → Appointment date string
    try {
      const apptDateObj = new Date(apptDateCell);               // → Convert to Date
      if (isNaN(apptDateObj.getTime())) {                       // → Invalid Date
        skipped++;                                              // → Increase skipped count
        continue;                                               // → Next row
      }
      apptYmd = Utilities.formatDate(                           // → Format as yyyy-MM-dd string
        apptDateObj,
        tz,
        'yyyy-MM-dd'
      );
    } catch (e) {                                               // → Conversion failed
      skipped++;                                                // → Increase skipped count
      continue;                                                 // → Next row
    }

    if (apptYmd !== targetYmd) {                                // → Not the target date
      continue;                                                 // → Skip
    }

    total++;                                                    // → Increase appointment count for target date

    const apptTimeCell = row[KPI_COL_APPT_TIME - 1];            // → Appointment time cell
    const checkinCell = KPI_COL_CHECKIN_AT > 0                  // → Arrival column configured?
      ? row[KPI_COL_CHECKIN_AT - 1]                             // → If yes, read value
      : null;                                                   // → Otherwise, null
    const checkoutCell = KPI_COL_CHECKOUT_AT > 0                // → Completion column configured?
      ? row[KPI_COL_CHECKOUT_AT - 1]                            // → If yes, read value
      : null;                                                   // → Otherwise, null

    // Only judge on-time/late if both appointment and arrival times are present
    if (apptTimeCell && checkinCell) {                          // → Both values present
      let apptDateTime;                                         // → Appointment DateTime
      try {
        apptDateTime = new Date(apptDateCell);                  // → Date at 00:00 based on appointment date
        if (apptDateTime instanceof Date && !isNaN(apptDateTime.getTime())) {
          // Handle cases where appointment time is a Date or a string
          let apptHours = 0;
          let apptMinutes = 0;

          if (apptTimeCell instanceof Date) {
            apptHours = apptTimeCell.getHours();
            apptMinutes = apptTimeCell.getMinutes();
          } else if (typeof apptTimeCell === 'string') {
            const parts = apptTimeCell.split(':');
            if (parts.length >= 2) {
              apptHours = Number(parts[0]);
              apptMinutes = Number(parts[1]);
            }
          }

          if (!Number.isFinite(apptHours) || !Number.isFinite(apptMinutes)) {
            throw new Error('Invalid appointment time value.');
          }

          apptDateTime.setHours(apptHours, apptMinutes, 0, 0);
        } else {
          throw new Error('Invalid appointment date value.');
        }
      } catch (e) {                                             // → Any conversion error
        skipped++;                                              // → Increase skipped count
        continue;                                               // → Next row
      }

      const checkinDateTime = new Date(checkinCell);            // → Arrival DateTime
      const ciMs = checkinDateTime.getTime();                   // → Arrival time in ms

      if (isFinite(apptDateTime.getTime()) &&                   // → Appointment time valid
          isFinite(ciMs)) {                                     // → Arrival time valid
        arrived++;                                              // → Increase arrival count
        const diffMs = ciMs - apptDateTime.getTime();           // → Time difference (ms)
        const diffMinutes = diffMs / 1000 / 60;                 // → Time difference (minutes)
        if (Number.isFinite(diffMinutes)) {                     // → Only if numeric
          if (Math.abs(diffMinutes) <= threshold) {             // → Within threshold
            ontime++;                                           // → Count as on-time
          }
        } else {                                                // → Not numeric
          skipped++;                                            // → Increase skipped count
        }
      } else {                                                  // → Invalid appointment/arrival time
        skipped++;                                              // → Increase skipped count
      }
    }

    // Only calculate dwell time if both arrival and completion times are present
    if (checkinCell && checkoutCell) {                          // → Both values present
      const checkinDateTime = new Date(checkinCell);            // → Arrival DateTime
      const checkoutDateTime = new Date(checkoutCell);          // → Completion DateTime
      const ciMs = checkinDateTime.getTime();                   // → Arrival ms
      const coMs = checkoutDateTime.getTime();                  // → Completion ms

      if (isFinite(ciMs) && isFinite(coMs) && coMs >= ciMs) {   // → Sanity check
        const dwellMinutes = (coMs - ciMs) / 1000 / 60;         // → Dwell time (minutes)
        if (Number.isFinite(dwellMinutes)) {                    // → Numeric only
          dwellSum += dwellMinutes;                             // → Add to sum
          dwellCount++;                                         // → Increase count
        } else {                                                // → Not numeric
          skipped++;                                            // → Increase skipped count
        }
      } else {                                                  // → Invalid time range
        skipped++;                                              // → Increase skipped count
      }
    }
  }

  const ontimeRate = arrived > 0                                // → Calculate on-time rate
    ? (ontime / arrived) * 100                                  // → Percentage
    : 0;                                                        // → 0 if denominator is 0

  const avgDwellMinutes = dwellCount > 0                        // → Average dwell time
    ? dwellSum / dwellCount                                     // → Average
    : 0;                                                        // → 0 if denominator is 0

  const result = {                                              // → Result object
    date: targetYmd,
    total,
    arrived,
    ontime,
    ontimeRate,
    avgDwellMinutes,
    skipped
  };

  Logger.log(JSON.stringify(result));                           // → Log summary
  saveDailyKpi_(result);                                        // → Write to KPI_DAILY
  return result;                                                // → Return result
}

This function can be reused not only for “today” but for any past date. Later, you can run it for specific high-volume or problematic days to spot patterns.


Step 3 — Implement function to save results into KPI_DAILY

Next is saveDailyKpi_(), which writes calculated KPIs into KPI_DAILY. If the sheet doesn’t exist, it creates it; if the date already exists, it updates the row. This makes it easy to change the on-time threshold (e.g., 15 → 30 minutes) later and recompute historical dates.

1) What this code does

  • In KPI_DAILY, finds a row with the same KPI date and overwrites it, or appends a new row if not found.

2) Where to paste

  • Directly below computeKpiForDate_().

3) What to do after pasting

  • It will be called automatically when you run KPI_testComputeToday_() in Step 4.
Apps Script (JavaScript)
function saveDailyKpi_(result) {                               // → Save daily KPI
  const ss = SpreadsheetApp.getActiveSpreadsheet();            // → Active spreadsheet
  let sheet = ss.getSheetByName(KPI_CONFIG.KPI_SHEET_NAME);    // → Find KPI_DAILY sheet
  if (!sheet) {                                                // → If missing
    sheet = ss.insertSheet(KPI_CONFIG.KPI_SHEET_NAME);         // → Create new sheet
    sheet.getRange(1, KPI_COL_KPI_DATE, 1, KPI_COL_KPI_CALC_AT) // → Header range
      .setValues([[
        'DATE',                                               // → KPI date
        'TOTAL_APPTS',                                        // → Total appointment count
        'ARRIVED_APPTS',                                      // → Count with arrival record
        'ONTIME_APPTS',                                       // → On-time arrival count
        'ONTIME_RATE_PCT',                                    // → On-time rate (%)
        'AVG_DWELL_MIN',                                      // → Average dwell time (minutes)
        'SKIPPED_APPTS',                                      // → Skipped row count
        'CALCULATED_AT'                                       // → Calculation timestamp
      ]]);                                                    
  }

  const lastRow = sheet.getLastRow();                          // → Last row number
  let targetRow = 0;                                           // → Target row to write into

  if (lastRow >= 2) {                                          // → If there is data
    const range = sheet.getRange(2, KPI_COL_KPI_DATE, lastRow - 1, 1); // → Date column range
    const values = range.getValues();                          // → 2D array of dates
    for (let i = 0; i < values.length; i++) {                  // → Scan each row
      const ymd = values[i][0];                                // → Date cell
      if (ymd === result.date) {                               // → Found matching date
        targetRow = i + 2;                                     // → Actual row index
        break;                                                 // → Stop scanning
      }
    }
  }

  if (!targetRow) {                                            // → No existing row
    targetRow = lastRow + 1;                                   // → Append new row
  }

  const now = new Date();                                      // → Current timestamp
  const rowValues = [                                          // → Values to write
    result.date,                                               // → KPI date
    result.total,                                              // → Total appointments
    result.arrived,                                            // → Arrivals with record
    result.ontime,                                             // → On-time arrivals
    result.ontimeRate,                                         // → On-time rate (%)
    result.avgDwellMinutes,                                    // → Average dwell time
    result.skipped,                                            // → Skipped count
    now                                                        // → Calculation timestamp
  ];

  sheet.getRange(targetRow, KPI_COL_KPI_DATE, 1, rowValues.length) 
    .setValues([rowValues]);                                   // → Write row

  // It’s generally better to adjust formatting on the sheet itself
  // for date strings and numeric values. Here we only set a basic
  // datetime format for the calculation timestamp column.
  sheet.getRange(2, KPI_COL_KPI_CALC_AT, sheet.getLastRow() - 1, 1) 
    .setNumberFormat('yyyy-MM-dd HH:mm');                      // → Timestamp format
}

Header labels like DATE, TOTAL_APPTS are in English to be convenient for pivot tables and charts. If you later want Korean display, you can add helper labels on the sheet UI.


Step 4 — Use a test function to calculate KPI for today

Finally, add a simple test function to check that the flow works end-to-end. It uses today’s date as the KPI date, runs the calculation, and logs the result.

1) What this code does

  • Uses today’s date to call computeKpiForDate_() and lets you check results in the execution log and in the KPI_DAILY sheet.

2) Where to paste

  • Directly below saveDailyKpi_().

3) What to do after pasting

  • In the Apps Script editor, choose KPI_testComputeToday_ from the function dropdown and click Run → grant permission once when prompted.
Apps Script (JavaScript)
function KPI_testComputeToday_() {                         // → Test function for today’s KPI
  const today = new Date();                               // → Today’s date
  const result = computeKpiForDate_(today);               // → Call KPI calculation
  Logger.log('KPI_testComputeToday_ result: ' +           // → Log result
    JSON.stringify(result));                              
}

After running it, open View → Execution log. If you see a line like KPI_testComputeToday_ result: {...} and a row for today appears in KPI_DAILY, your entire flow is working.


Practical tips: data quality, time columns, and error handling

From real warehouse operations with Google Sheets KPIs, several points turned out to be especially important:

1. Set time columns in two stages: “0 → actual index”

In this post, KPI_COL_CHECKIN_AT and KPI_COL_CHECKOUT_AT default to 0. That lets you deploy on-time & dwell-time code before you formally roll out check-in in your sheet, and once the structure is fixed, you just fill in the column indexes.

  • After adding check-in/checkout columns to APPT_MAIN,

e.g., if check-in is column N and check-out is column O:

KPI_COL_CHECKIN_AT = 14;

KPI_COL_CHECKOUT_AT = 15;

then rerun the test function.

2. Use validation rules to lock down time formats

On-time and dwell-time KPIs become more accurate the more consistent your time inputs are.

  • Ideally, record check-in/checkout times only through a web app (e.g., the saveCheckInData() function from “Inbound appointment check-in, part 4”) and minimize direct edits in the sheet.
  • If manual edits are unavoidable, set the entire columns to “Time” format and add data validation (e.g., HH:MM pattern).

Once text values like “9 o’clock”, “late arrival” creep into time columns, those rows will automatically become skipped. Eventually you’ll spend time debugging why KPIs look off.

3. Validate with a small, hand-crafted sample date first

Instead of trying to validate the entire production dataset at once, choose a day with 3–5 appointments and set up the following pattern:

  • 2 clearly on-time arrivals (within ±10 minutes of appointment time)
  • 1 late arrival (e.g., +40 minutes)
  • 1 reservation with no arrival record
  • 1 with arrival but no completion time

Run computeKpiForDate_(new Date('YYYY-MM-DD')) directly, or adjust dates so that date equals today and run KPI_testComputeToday_(). Then check:

  • Does on-time rate come out to roughly 2 / 3 × 100?
  • Is average dwell time based only on rows where both arrival and completion are recorded?

If numbers diverge from expectation, tighten logging around that date only and step through why.

4. Common errors and quick fixes

  • Could not find appointment sheet (APPT_MAIN).

→ Double-check your actual appointment sheet name vs. KPI_CONFIG.APPT_SHEET_NAME, including capitalization and spaces.

  • Invalid Date object.

→ You likely called computeKpiForDate_() with a raw string.

Always wrap it: computeKpiForDate_(new Date('2026-08-23')).


Closing: start by logging just one day of KPIs

In this post, we built the first layer of a Google Sheets reservation KPI aggregation method: an Apps Script that auto-calculates daily on-time rate and average dwell time. The key points were:

  • Use computeKpiForDate_() for per-date KPI calculation
  • Store results as “one row per day” in KPI_DAILY
  • Surface data quality issues explicitly via a skipped count

If you do just one thing right now,

pick a day with a small number of appointments, paste this code, and run KPI_testComputeToday_().

The moment you see that day’s on-time rate and average dwell time as numbers:

  • You’ll start to see which additional KPIs you want to slice
  • You’ll imagine how to segment by carrier and customer
  • You’ll see where delays and long dwell times tend to concentrate

much more clearly.

In the next part, we’ll build on this daily KPI data to create carrier- and customer-level reports for on-time arrival rate and dwell time, and we’ll walk through constructing a simple dashboard around them.