跳到主要內容跳到頁尾

Apps Script 時間觸發器:每週一早上自動寄出值班表

7 分鐘閱讀Lv.3 中級摘要與日期

用時間觸發器讓試算表每週一自動寄出值班表:先算出這一週的週一、找到對應的列、再寄信;也示範安裝與清理觸發器,重複安裝只留一個。附授權與執行身分說明。

發布

文章目錄

每週一早上,要把「這週誰值班、誰備援」寄給同事。每次手動寄會忘,所以交給試算表:到了週一,它自己讀值班表、組出信、寄出去。

這件事要解決三個問題:什麼時候執行、怎麼決定「這週」是哪一週、重複安裝觸發器會不會寄兩次。

先看流程

概念示意:時間觸發器在每週一的某個小時叫醒 sendWeeklyRoster;程式用現在的時間算出這週的週一,在值班表找對應那一列,找到就寄信,找不到只寫記錄;收件者寫在程式裡,觸發器以建立它的人的身分執行。← 左右滑動看完整的圖,點圖可放大

觸發器只負責叫醒函式。要寄給誰、寄哪一週,都由函式自己判斷。

先準備

  • 試算表建一個「值班表」分頁,第 1 列是表頭,之後每週一列:A 欄是該週週一的日期(要是真的日期,不是文字)、B 欄值班人、C 欄備援。
  • 在「擴充功能 → Apps Script」把四個區塊依序貼進去並儲存。
  • 把 TEST_RECIPIENT 換成你自己的信箱,先寄給自己測試。
  • 函式選單選 sendWeeklyRoster 按「執行」。這支程式會寄信、也會建觸發器,Google 會分別要求授權;授權畫面列出的權限請自己看清楚再同意。
  • 專案的時區與試算表的時區,建議設成一樣:觸發器的「8 點」依專案時區(官方文件寫預設是腳本的時區),值班表的日期依試算表時區。

區塊一:算出這一週的週一

程式碼.gs
Apps Script
/**
 * @OnlyCurrentDoc
 */

const SHEET_NAME = "值班表";
const HANDLER = "sendWeeklyRoster";
const TEST_RECIPIENT = "you@example.com"; // 換成你要收信的信箱;收件者寫在程式裡,不從儲存格讀

function isDate(value) {
  return Object.prototype.toString.call(value) === "[object Date]";
}

// 這一天所屬那一週的週一 00:00(週日算前一個週一)
function mondayOf(date) {
  const monday = new Date(date.getFullYear(), date.getMonth(), date.getDate());
  monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
  return monday;
}

function formatDay(date) {
  const pad = (n) => (n < 10 ? "0" + n : String(n));
  return date.getFullYear() + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate());
}

// 把某個時間點換成指定時區看到的年月日(本地午夜),腳本時區與試算表時區不同時才不會差一天
function dayInZone(date, timeZone) {
  const [year, month, day] = Utilities.formatDate(date, timeZone, "yyyy-MM-dd").split("-").map(Number);
  return new Date(year, month - 1, day);
}
  • mondayOf 回傳某一天所屬那一週的週一 00:00。週日算前一個週一,所以週日、週六執行都會對到同一週。
  • 它只用年月日欄位計算,不改傳進去的日期。
  • HANDLER 是觸發器要叫醒的函式名稱,後面的清理也靠它。
  • formatDay 把日期寫成 2026-10-12 這種樣子,主旨與記錄都用它。
  • dayInZone 把某個時間點換成指定時區看到的年月日。專案時區與試算表時區不同時,儲存格的日期與「現在」才會對到同一天。

區塊二:找本週那一列、組出信

程式碼.gs
Apps Script
// rows 每列:[週一日期, 值班人, 備援];只比年月日,找不到回傳 null
function pickWeekRow(rows, monday) {
  const found = rows.find((row) => isDate(row[0]) && mondayOf(row[0]).getTime() === monday.getTime());
  return found || null;
}

function buildRosterMessage(row, monday) {
  return {
    subject: "本週值班表(" + formatDay(monday) + " 起)",
    body: "值班人:" + row[1] + "\n備援:" + row[2],
  };
}
  • pickWeekRow 只比年月日,所以儲存格裡的日期帶了時間也找得到;空白列與非日期的列會被跳過;找不到回傳 null。
  • buildRosterMessage 只組信,不寄。主旨含該週週一,內文是值班人與備援。

區塊三:觸發器叫醒的函式

程式碼.gs
Apps Script
// 觸發器要執行的函式:用「現在」算出這週的週一,找到那一列才寄
function sendWeeklyRoster() {
  const book = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = book.getSheetByName(SHEET_NAME);
  if (!sheet) {
    throw new Error("找不到分頁「" + SHEET_NAME + "」");
  }
  const timeZone = book.getSpreadsheetTimeZone();
  const monday = mondayOf(dayInZone(new Date(), timeZone));
  const last = sheet.getLastRow();
  const rows = (last < 2 ? [] : sheet.getRange(2, 1, last - 1, 3).getValues()).map((row) =>
    isDate(row[0]) ? [dayInZone(row[0], timeZone), row[1], row[2]] : row
  );
  const row = pickWeekRow(rows, monday);
  if (row === null) {
    Logger.log("值班表沒有 " + formatDay(monday) + " 這週,不寄信");
    return;
  }
  const message = buildRosterMessage(row, monday);
  MailApp.sendEmail(TEST_RECIPIENT, message.subject, message.body);
  Logger.log("已寄出:" + message.subject);
}
  • 「業務日」就是這一週的週一,用現在的時間算出來,不是寫死的日期。
  • 「現在」與值班表的日期,都先換成試算表時區的年月日再比。
  • 值班表找不到這一週就寫記錄、結束,不寄信;整個分頁不存在則丟錯。
  • 收件者是 TEST_RECIPIENT,寫在程式裡。就算有人在儲存格填了別的信箱,信也不會寄過去。

區塊四:安裝與清理觸發器

程式碼.gs
Apps Script
// 只刪自己這個函式的觸發器,回傳刪了幾個
function removeWeeklyTrigger() {
  let removed = 0;
  ScriptApp.getProjectTriggers().forEach((trigger) => {
    if (trigger.getHandlerFunction() === HANDLER) {
      ScriptApp.deleteTrigger(trigger);
      removed++;
    }
  });
  return removed;
}

// 先清掉舊的再建,連按幾次都只會留一個
function installWeeklyTrigger() {
  removeWeeklyTrigger();
  ScriptApp.newTrigger(HANDLER).timeBased().onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(8).create();
}

function listTriggers() {
  const triggers = ScriptApp.getProjectTriggers();
  if (triggers.length === 0) {
    Logger.log("沒有觸發器");
    return;
  }
  triggers.forEach((trigger) => {
    Logger.log(trigger.getHandlerFunction() + " / " + trigger.getEventType());
  });
}
  • installWeeklyTrigger 建立「每週一、早上 8 點那個小時」的時間觸發器。atHour(8) 不是 8:00 整,分鐘不固定。
  • 建立前先呼叫 removeWeeklyTrigger,只刪這個函式自己的觸發器,其他函式的不動。所以重複執行安裝,只會留一個。
  • listTriggers 把現有觸發器列進執行記錄,確認有沒有裝好。
  • 安裝只要執行一次。想停止每週寄信,執行 removeWeeklyTrigger。
  • 官方文件寫 getProjectTriggers 只回傳目前使用者在這個專案的觸發器。所以清理與安裝只看得到你自己帳號建的;共用試算表時如果兩個人各自執行安裝,可能各建一個,每週一就會寄兩封。

執行結果

下面用範例資料示範。「值班表」是:

列 / 欄ABC
1週一日期值班人備援
22026-10-12小美阿明
32026-10-19阿明小華
42026-10-26小華小美
52026-11-02小美阿明

示範試算表

當天是週六,執行 sendWeeklyRoster 後,執行記錄是「已寄出:本週值班表(2026-10-12 起)」,程式回推到前一個週一 10 月 12 日,找到第一列。信的主旨與內文如下(信箱換成範例,內容是同一段程式組出的):

列 / 欄ABCD
1收件者主旨值班人備援
2you@example.com本週值班表(2026-10-12 起)小美阿明

示範試算表

接著把 installWeeklyTrigger 執行兩次,再執行 listTriggers,執行記錄只有一行 sendWeeklyRoster / CLOCK。最後執行 removeWeeklyTrigger,回傳 1,再列一次是「沒有觸發器」。這兩個表是預先寫好的結果,不會自己執行程式。

這支程式以誰的身分執行

項目說明
以誰的身分執行官方文件寫:安裝式觸發器以建立它的人的帳號執行
需要哪些授權寄信與管理觸發器。要求的權限是 script.send_mail 與 script.scriptapp
收件者從哪來寫在程式的 TEST_RECIPIENT,不從儲存格讀
觸發時間不保證整點;官方文件說明沒呼叫 nearMinute 時,分鐘是隨機的
每日用量寄信與觸發器執行時間都有每日配額,數字以官方配額頁為準

換成你的情境

  • 週幾、幾點:onWeekDay 與 atHour。
  • 分頁與欄位:SHEET_NAME,以及區塊二的欄位順序。
  • 收件者:TEST_RECIPIENT;要寄給多人,可以改成固定的一串信箱,不要從儲存格讀。
  • 信的內容:buildRosterMessage。
1

改成每月 1 日寄上個月的彙總

如果要改成「每月 1 日早上寄上個月的彙總」,「業務日」要怎麼算?觸發器要改哪裡?

延伸閱讀

分享:
#Apps Script#觸發器#MailApp#值班表
喜特先生 Mr. Sheet

喜特先生 Mr. Sheet

數據分析師/商業分析師

八年工作經驗,跨不同業界解決數據需求。做過的案例都整理在作品集。

接下來可以

相關文章

查看所有文章

留言討論

共 0 則留言

訂閱電子報,新文章上線就通知你

免費,隨時可取消。

送出即表示同意服務條款與隱私權政策。