スポンサーリンク

Notionオートメーションで期限超過タスクを自動通知させてみた

こんにちは、ヤスムラです。

みなさんNotionでのタスク管理機能使っていますか?
標準のインテグレーション(オートメーション)機能を使えば、ステータスの変更をトリガーにしてSlackやメールに通知が送れるので非常に便利な機能ですよね。

インテグレーション – Notion (ノーション)
お気に入りのツールをNotionとつなげて、ニーズにぴったりのワークフローを構築します。

ただ一個おしいところがあって標準機能では、期日超過のアラート(通知)機能はありません。

「気づいたら期限が昨日だった…」
「定期的に期日をチェックするのが大変でなんとかしたい…」

という方がいらっしゃるのではないかと思います。
安心して下さい。今回はこの課題を解決するためのツールを作成してみました。

解決方法

Google Apps Script (GAS) を使って期日超過したタスクのステータスを自動変更することで、Notionインテグレーションで通知させる という方法をとります。

GASで期限切れタスクのステータスを「超過」などに書き換えてあげることで、Notion側のオートメーション機能が作動し、Slackやメールに通知が飛ぶようにします。

事前準備

まずは以下3つの準備を行います。

1. Notion 通知設定

タスクの超過ステータスを管理するプロパティが変更された際に、通知が飛ぶようにNotionインテグレーションを設定して下さい。今回はSlackで通知されるようにします。

Slackとのインテグレーション – Notion (ノーション)ヘルプセンター
NotionとSlackを連携させると、どこからでもより良い仕事ができるようになります。

2. Notion インテグレーション(APIキー)作成

外部ツール(GAS)からNotionを操作するためこちらのリンクからインテグレーションを作成して控えておいて下さい。なお今回はステータス変更を行うので更新権限も付与して下さい。

Notion APIインテグレーション – Notion (ノーション)ヘルプセンター
NotionのAPIを使用すれば、内部インテグレーションをカスタマイズして作成できます。Notionのワークスペースに弊社パートナーのプラットフォームをリンクするには、トークンが必要な場合があります。以下で詳しい手順を解説します 🏗

3. データベースへの接続

Notion APIキーを作成したら、対象のデータベースの右上「…」から「接続」 を選択しコネクトの追加を行って下さい。

インテグレーションの追加・管理 – Notion (ノーション)ヘルプセンター
Notion APIを使用すれば、他のソフトウェアをNotionに接続してワークスペース内のアクションを自動化したり、弊社パートナーが構築したコネクトにアクセスしたりできます 🤖

4. GASの作成

Googleスプレッドシートを新規作成し、任意の名前(例:Notion期限管理ツール)を付けます。 その後、[拡張機能] > [Apps Script] を開き、以下のコードをすべて貼り付けて保存してください。

/**
 * Notion期限超過ステータス自動更新ツール
 * * 概要:
 * トリガー設定の強化(平日・毎週対応)、ログ出力の適正化、UIのシンプル化を実施。
 * HTML設定画面をコード内に内包。
 */

// ----------------------------------------
// 設定・定数
// ----------------------------------------
const APP_NAME = "Notion期限管理BOT";
const PROPS = PropertiesService.getScriptProperties();

const SHEET_CONFIG = {
  NAME: '管理コンソール',
  HEADERS: [
    '実行ON',             // A列
    'DB名称(メモ)',        // B列
    'Notion DB URL',      // C列
    '期日の列',            // D列 (判定用)
    '完了ステータス',       // E列 (判定用)
    '超過ステータス',       // F列 (更新用)
    '完了の値',            // G列
    '超過時の値',          // H列
    '実行ログ'            // I列
  ],
  WIDTHS: [50, 150, 250, 150, 150, 180, 150, 150, 250],
  COLORS: [
    '#999999', '#4a86e8', '#4a86e8', 
    '#6aa84f', '#6aa84f', '#6aa84f', 
    '#f1c232', '#f1c232', '#d9d9d9'
  ],
  SAMPLE: [
    false, 'タスク管理DB', 'https://www.notion.so/...', 
    '(メニュー2で取得)', '(メニュー2で取得)', '(メニュー2で取得)', 
    '(メニュー3で取得)', '(メニュー3で取得)', ''
  ]
};

const COL_INDEX = {
  ACTIVE: 0, NAME: 1, URL: 2,
  DATE_PROP: 3, READ_STATUS_PROP: 4, WRITE_OVERDUE_PROP: 5,
  DONE_VALS: 6, OVERDUE_VAL: 7, LOG: 8
};

// ----------------------------------------
// UI・メニュー関連
// ----------------------------------------

function onOpen() {
  const ui = SpreadsheetApp.getUi();
  ui.createMenu('⚡ Notion連携機能')
    .addItem('1. 初期セットアップ', 'performInitialSetup')
    .addSeparator()
    .addItem('2. 空欄の列設定を一括取得', 'fetchAndSetPropDropdownsBatch')
    .addItem('3. 空欄の値設定を一括取得', 'fetchAndSetValueDropdownsBatch')
    .addSeparator()
    .addItem('4. 期限切れチェック実行', 'executeOverdueCheck')
    .addItem('5. 自動実行スケジュール設定', 'showTriggerSettingsDialog')
    .addToUi();
}

// ----------------------------------------
// 1. 初期セットアップ
// ----------------------------------------

function performInitialSetup() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();

  let sheet = ss.getSheetByName(SHEET_CONFIG.NAME);
  if (sheet) {
    const res = ui.alert('警告', '既にシートが存在します。\n再作成しますか?', ui.ButtonSet.YES_NO);
    if (res == ui.Button.NO) return;
    ss.deleteSheet(sheet);
  }

  // 管理コンソール作成
  sheet = ss.insertSheet(SHEET_CONFIG.NAME, 0);
  formatSheet_(sheet);
  
  // 既存の全シートをチェックし、管理コンソール以外で「完全に空」なシートは削除する
  const allSheets = ss.getSheets();
  allSheets.forEach(targetSheet => {
    if (targetSheet.getName() === SHEET_CONFIG.NAME) return;
    if (targetSheet.getLastRow() === 0 && targetSheet.getLastColumn() === 0) {
      try { ss.deleteSheet(targetSheet); } catch(e) {}
    }
  });
  
  ui.alert('作成完了', '管理コンソールを作成しました。\nNotion APIキーの設定に進みます。', ui.ButtonSet.OK);
  setNotionApiKey();
}

function formatSheet_(sheet) {
  sheet.clear();
  const headerRange = sheet.getRange(1, 1, 1, SHEET_CONFIG.HEADERS.length);
  headerRange.setValues([SHEET_CONFIG.HEADERS])
    .setFontColor('white').setFontWeight('bold').setHorizontalAlignment('center');
  
  SHEET_CONFIG.COLORS.forEach((color, i) => sheet.getRange(1, i + 1).setBackground(color));
  SHEET_CONFIG.WIDTHS.forEach((w, i) => sheet.setColumnWidth(i + 1, w));
  
  sheet.getRange(2, 1, 100, 1).insertCheckboxes();
  sheet.getRange(2, 1, 1, SHEET_CONFIG.HEADERS.length).setValues([SHEET_CONFIG.SAMPLE]).setFontColor('#808080');
  sheet.getRange(1, 1, 101, SHEET_CONFIG.HEADERS.length).setBorder(true, true, true, true, true, true, '#d9d9d9', SpreadsheetApp.BorderStyle.SOLID);
  sheet.setFrozenRows(1);
}

function setNotionApiKey() {
  const ui = SpreadsheetApp.getUi();
  const storedKey = PROPS.getProperty('NOTION_API_KEY');
  
  const msg = storedKey 
    ? '設定済みです。変更する場合のみ入力してください。\n(空白のままOKを押すと既存の設定を維持します)' 
    : 'Notion Integration Tokenを入力してください';
    
  const res = ui.prompt('Notion API Key設定', msg, ui.ButtonSet.OK_CANCEL);
  
  if (res.getSelectedButton() == ui.Button.OK) {
    const inputKey = res.getResponseText().trim();

    if (inputKey === '') {
      if (storedKey) {
        ui.alert('確認', '入力がなかったため、既存のAPIキーを維持します。', ui.ButtonSet.OK);
        return;
      } else {
        ui.alert('エラー', 'APIキーが入力されていません。', ui.ButtonSet.OK);
        return;
      }
    }

    if (!inputKey.startsWith('secret_') && !inputKey.startsWith('ntn_')) {
      ui.alert('エラー', 'キー形式が不正です。("secret_" または "ntn_" で始まる必要があります)', ui.ButtonSet.OK);
      return;
    }

    PROPS.setProperty('NOTION_API_KEY', inputKey);
    ui.alert('保存完了', 'APIキーを保存しました。', ui.ButtonSet.OK);
  }
}

// ----------------------------------------
// 2. プロパティ(列名) 一括自動取得
// ----------------------------------------

function fetchAndSetPropDropdownsBatch() {
  const { sheet, apiKey } = getContext_();
  if (!sheet || !apiKey) return;

  const data = sheet.getDataRange().getValues();
  let processedCount = 0;

  for (let i = 1; i < data.length; i++) {
    const row = data[i];
    const rowIndex = i + 1;
    
    const hasUrl = !!row[COL_INDEX.URL];
    const needsSetup = !row[COL_INDEX.DATE_PROP] || !row[COL_INDEX.READ_STATUS_PROP] || !row[COL_INDEX.WRITE_OVERDUE_PROP];

    if (hasUrl && needsSetup) {
      try {
        const dbId = extractDatabaseId_(row[COL_INDEX.URL]);
        if (!dbId) throw new Error('URL不正');

        const dbSchema = fetchDatabaseSchema_(apiKey, dbId);
        
        const dateProps = [];
        const statusProps = [];
        
        Object.keys(dbSchema.properties).forEach(key => {
          const prop = dbSchema.properties[key];
          if (prop.type === 'date') dateProps.push(key);
          else if (['select', 'status', 'checkbox'].includes(prop.type)) statusProps.push(key);
        });

        if (dateProps.length === 0 && statusProps.length === 0) throw new Error('対象プロパティなし');

        const dateRule = SpreadsheetApp.newDataValidation().requireValueInList(dateProps).setAllowInvalid(true).build();
        const statusRule = SpreadsheetApp.newDataValidation().requireValueInList(statusProps).setAllowInvalid(true).build();

        sheet.getRange(rowIndex, COL_INDEX.DATE_PROP + 1).setDataValidation(dateRule);
        sheet.getRange(rowIndex, COL_INDEX.READ_STATUS_PROP + 1).setDataValidation(statusRule);
        sheet.getRange(rowIndex, COL_INDEX.WRITE_OVERDUE_PROP + 1).setDataValidation(statusRule);
        
        sheet.getRange(rowIndex, COL_INDEX.LOG + 1).setValue('列リスト取得完了').clearNote();
        processedCount++;
        Utilities.sleep(400); 

      } catch (e) {
        console.error(`Row ${rowIndex}: ${e.message}`);
        sheet.getRange(rowIndex, COL_INDEX.LOG + 1).setValue(`エラー: ${e.message}`);
      }
    }
  }

  const msg = processedCount > 0 
    ? `${processedCount}件の行にプルダウンを設定しました。` 
    : '設定が必要な行は見つかりませんでした。\n(URL未入力 または 既に設定済み)';
  
  SpreadsheetApp.getUi().alert('処理完了', msg, SpreadsheetApp.getUi().ButtonSet.OK);
}

// ----------------------------------------
// 3. 選択肢(値) 一括自動取得
// ----------------------------------------

function fetchAndSetValueDropdownsBatch() {
  const { sheet, apiKey } = getContext_();
  if (!sheet || !apiKey) return;

  const data = sheet.getDataRange().getValues();
  let processedCount = 0;

  for (let i = 1; i < data.length; i++) {
    const row = data[i];
    const rowIndex = i + 1;

    const readyToFetch = row[COL_INDEX.URL] && row[COL_INDEX.READ_STATUS_PROP] && row[COL_INDEX.WRITE_OVERDUE_PROP];
    const needsSetup = !row[COL_INDEX.DONE_VALS] || !row[COL_INDEX.OVERDUE_VAL];

    if (readyToFetch && needsSetup) {
      try {
        const dbId = extractDatabaseId_(row[COL_INDEX.URL]);
        const dbSchema = fetchDatabaseSchema_(apiKey, dbId);
        
        const readOptions = getPropOptions_(dbSchema.properties[row[COL_INDEX.READ_STATUS_PROP]]);
        const writeOptions = getPropOptions_(dbSchema.properties[row[COL_INDEX.WRITE_OVERDUE_PROP]]);

        if (readOptions.length > 0) {
          const rule = SpreadsheetApp.newDataValidation().requireValueInList(readOptions).setAllowInvalid(true).build();
          sheet.getRange(rowIndex, COL_INDEX.DONE_VALS + 1).setDataValidation(rule);
          sheet.getRange(rowIndex, COL_INDEX.DONE_VALS + 1).setNote('【TIPS】複数入力はカンマ(,)区切り');
        }

        if (writeOptions.length > 0) {
          const rule = SpreadsheetApp.newDataValidation().requireValueInList(writeOptions).setAllowInvalid(true).build();
          sheet.getRange(rowIndex, COL_INDEX.OVERDUE_VAL + 1).setDataValidation(rule);
        }

        sheet.getRange(rowIndex, COL_INDEX.LOG + 1).setValue('値リスト取得完了').clearNote();
        processedCount++;
        Utilities.sleep(400);

      } catch (e) {
        console.error(`Row ${rowIndex}: ${e.message}`);
        sheet.getRange(rowIndex, COL_INDEX.LOG + 1).setValue(`値取得エラー: ${e.message}`);
      }
    }
  }

  const msg = processedCount > 0 
    ? `${processedCount}件の行に値のプルダウンを設定しました。` 
    : '設定が必要な行は見つかりませんでした。\n(プロパティ未設定 または 既に設定済み)';

  SpreadsheetApp.getUi().alert('処理完了', msg, SpreadsheetApp.getUi().ButtonSet.OK);
}

function getPropOptions_(propObj) {
  if (!propObj) return [];
  try {
    if (propObj.type === 'select') return propObj.select?.options?.map(o => o.name) || [];
    if (propObj.type === 'status') {
      let opts = [];
      if (propObj.status?.options) opts = opts.concat(propObj.status.options.map(o => o.name));
      if (propObj.status?.groups) propObj.status.groups.forEach(g => { if (g.options) opts = opts.concat(g.options.map(o => o.name)); });
      return opts;
    }
    if (propObj.type === 'checkbox') return ['true', 'false'];
  } catch (e) { return []; }
  return [];
}

// ----------------------------------------
// 4. 期限切れチェック実行
// ----------------------------------------

function executeOverdueCheck() {
  const { sheet, apiKey } = getContext_();
  if (!sheet || !apiKey) return;

  const data = sheet.getDataRange().getValues();
  const today = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd');
  let processed = 0;

  for (let i = 1; i < data.length; i++) {
    const row = data[i];
    const rowIndex = i + 1;
    const logCell = sheet.getRange(rowIndex, COL_INDEX.LOG + 1);

    // 実行ONがチェックされていない行はスキップ
    if (!row[COL_INDEX.ACTIVE]) continue;

    // 設定不備チェック
    if (!row[COL_INDEX.URL] || !row[COL_INDEX.DATE_PROP] || !row[COL_INDEX.READ_STATUS_PROP] || !row[COL_INDEX.WRITE_OVERDUE_PROP] || !row[COL_INDEX.OVERDUE_VAL]) {
      logCell.setValue('設定不備あり').setBackground('#fff2cc');
      continue;
    }

    const dbId = extractDatabaseId_(row[COL_INDEX.URL]);
    if (!dbId) { logCell.setValue('URL無効').setBackground('#f4cccc'); continue; }

    try {
      const result = processDatabaseRow_(apiKey, dbId, row, today);
      
      // ログ出力 (セルには概要、Consoleには詳細)
      logCell.setValue(result.message).setBackground('#d9ead3').clearNote();
      
      if (result.updatedUrls.length > 0) {
        console.log(`[Row ${rowIndex} Updated] ${row[COL_INDEX.NAME]}:`, result.updatedUrls);
      }
      processed++;
    } catch (e) {
      logCell.setValue(`エラー: ${e.message}`).setBackground('#f4cccc').clearNote();
      console.error(`[Row ${rowIndex} Error] ${e.message}`);
    }
    
    // UI反映のためフラッシュ
    SpreadsheetApp.flush();
    Utilities.sleep(500);
  }
  
  try { 
    if(processed > 0) SpreadsheetApp.getActiveSpreadsheet().toast(`${processed}件のDBを処理しました`); 
    else SpreadsheetApp.getActiveSpreadsheet().toast('処理対象がありませんでした (実行ONを確認してください)');
  } catch(e){}
}

function processDatabaseRow_(apiKey, dbId, row, today) {
  const dateProp = row[COL_INDEX.DATE_PROP];
  const readProp = row[COL_INDEX.READ_STATUS_PROP];
  const writeProp = row[COL_INDEX.WRITE_OVERDUE_PROP];
  const doneVals = (row[COL_INDEX.DONE_VALS] ? row[COL_INDEX.DONE_VALS].toString() : "").split(',').map(s => s.trim());
  const overdueVal = row[COL_INDEX.OVERDUE_VAL];

  const pages = queryNotion_(apiKey, dbId, dateProp, today);
  if (pages.length === 0) return { message: `対象なし (${formatDate_()})`, updatedUrls: [] };

  let count = 0;
  const updatedUrls = [];

  pages.forEach(page => {
    const props = page.properties;
    if (!props[readProp]) throw new Error(`列「${readProp}」不明`);
    if (!props[writeProp]) throw new Error(`列「${writeProp}」不明`);

    const readVal = extractPropValue_(props[readProp]);
    if (doneVals.includes(readVal)) return;

    const currentWriteVal = extractPropValue_(props[writeProp]);
    if (currentWriteVal === overdueVal) return;
    if (props[writeProp].type === 'checkbox' && String(currentWriteVal) === overdueVal) return;

    updateNotion_(apiKey, page.id, writeProp, props[writeProp].type, overdueVal);
    count++;
    updatedUrls.push(page.url);
    Utilities.sleep(200);
  });
  
  return { message: `更新:${count}件 / 対象:${pages.length}件 (${formatDate_()})`, updatedUrls: updatedUrls };
}

function extractPropValue_(propObj) {
  if (propObj.type === 'select') return propObj.select ? propObj.select.name : '';
  if (propObj.type === 'status') return propObj.status ? propObj.status.name : '';
  if (propObj.type === 'checkbox') return propObj.checkbox ? 'true' : 'false';
  return '';
}

// ----------------------------------------
// API Utils
// ----------------------------------------

function fetchDatabaseSchema_(apiKey, dbId) {
  const url = `https://api.notion.com/v1/databases/${dbId}`;
  const res = UrlFetchApp.fetch(url, {
    method: 'get', headers: { 'Authorization': `Bearer ${apiKey}`, 'Notion-Version': '2022-06-28' }, muteHttpExceptions: true
  });
  if (res.getResponseCode() !== 200) throw new Error(JSON.parse(res.getContentText()).message);
  return JSON.parse(res.getContentText());
}

function queryNotion_(apiKey, dbId, dateProp, today) {
  const url = `https://api.notion.com/v1/databases/${dbId}/query`;
  const payload = { filter: { property: dateProp, date: { before: today } } };
  const res = UrlFetchApp.fetch(url, {
    method: 'post', headers: { 'Authorization': `Bearer ${apiKey}`, 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json' },
    payload: JSON.stringify(payload), muteHttpExceptions: true
  });
  if (res.getResponseCode() !== 200) throw new Error(JSON.parse(res.getContentText()).message);
  return JSON.parse(res.getContentText()).results;
}

function updateNotion_(apiKey, pageId, propName, type, newVal) {
  const url = `https://api.notion.com/v1/pages/${pageId}`;
  let propData;
  if (type === 'checkbox') propData = { checkbox: (newVal === 'true') };
  else propData = { [type]: { name: newVal } };
  const payload = { properties: { [propName]: propData } };
  
  UrlFetchApp.fetch(url, {
    method: 'patch', headers: { 'Authorization': `Bearer ${apiKey}`, 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json' },
    payload: JSON.stringify(payload), muteHttpExceptions: true
  });
}

function extractDatabaseId_(url) {
  const match = url ? url.match(/([a-f0-9]{32})/) : null;
  return match ? match[1] : null;
}

// ----------------------------------------
// Helper Utils & HTML Trigger (Embedded)
// ----------------------------------------

function getContext_() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(SHEET_CONFIG.NAME);
  const apiKey = PROPS.getProperty('NOTION_API_KEY');
  if (!sheet) { SpreadsheetApp.getUi().alert('シートが見つかりません'); return {}; }
  if (!apiKey) { SpreadsheetApp.getUi().alert('APIキーが未設定です'); return {}; }
  return { sheet, apiKey };
}

/** HTMLファイルを読み込む代わりに文字列から生成する */
function showTriggerSettingsDialog() {
  const htmlString = getTriggerUiHtml_();
  const html = HtmlService.createHtmlOutput(htmlString).setWidth(420).setHeight(400);
  SpreadsheetApp.getUi().showModalDialog(html, '⏰ 自動実行設定');
}

function setupTriggerFromClient(config) {
  deleteAllTriggers_(true);
  const hour = parseInt(config.hour);
  let msg = "";

  if (config.freq === 'DAILY') {
    ScriptApp.newTrigger('executeOverdueCheck').timeBased().everyDays(1).atHour(hour).create();
    msg = `毎日 ${hour}時台 に実行設定しました。`;
  } else if (config.freq === 'WEEKDAY') {
    // 月〜金まで5つのトリガーを作成
    [ScriptApp.WeekDay.MONDAY, ScriptApp.WeekDay.TUESDAY, ScriptApp.WeekDay.WEDNESDAY, ScriptApp.WeekDay.THURSDAY, ScriptApp.WeekDay.FRIDAY].forEach(day => {
      ScriptApp.newTrigger('executeOverdueCheck').timeBased().onWeekDay(day).atHour(hour).create();
    });
    msg = `平日(月〜金) ${hour}時台 に実行設定しました。`;
  } else if (config.freq === 'WEEKLY') {
    ScriptApp.newTrigger('executeOverdueCheck').timeBased().onWeekDay(ScriptApp.WeekDay[config.day]).atHour(hour).create();
    msg = `毎週 ${config.day}曜日の ${hour}時台 に実行設定しました。`;
  }

  return msg;
}

function menuDeleteTriggers() { deleteAllTriggers_(false); }
function deleteAllTriggers_(silent) {
  ScriptApp.getProjectTriggers().forEach(t => ScriptApp.deleteTrigger(t));
  if (!silent) SpreadsheetApp.getUi().alert('完了', 'トリガー解除完了', SpreadsheetApp.getUi().ButtonSet.OK);
}
function formatDate_() { return Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'MM/dd HH:mm'); }

/** HTMLの中身をここに定義(ファイルレス化) */
function getTriggerUiHtml_() {
  return `
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <style>
      body { font-family: 'Segoe UI', sans-serif; padding: 20px; color: #333; background: #f9f9f9; }
      h2 { font-size: 16px; margin: 0 0 10px 0; color: #4a86e8; border-bottom: 2px solid #4a86e8; padding-bottom: 10px; }
      .form-group { margin-bottom: 20px; background: white; padding: 15px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
      label { display: block; margin-bottom: 8px; font-weight: bold; font-size: 13px; }
      select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; background: #fff; }
      .note { font-size: 11px; color: #666; margin-top: 8px; line-height: 1.4; }
      button { width: 100%; padding: 12px; background: #4a86e8; color: white; border: none; border-radius: 4px; font-weight: bold; cursor: pointer; }
      button:hover { background: #3c78d8; }
      button:disabled { background: #ccc; cursor: not-allowed; }
    </style>
  </head>
  <body>
    <h2>自動実行スケジュールの設定</h2>
    
    <div class="form-group">
      <label>実行頻度</label>
      <select id="freq" onchange="toggleDaySelect()">
        <option value="DAILY">毎日</option>
        <option value="WEEKDAY">平日 (月〜金)</option>
        <option value="WEEKLY">毎週 (曜日指定)</option>
      </select>
    </div>

    <div class="form-group" id="day-group" style="display:none;">
      <label>曜日</label>
      <select id="day">
        <option value="MONDAY">月曜日</option>
        <option value="TUESDAY">火曜日</option>
        <option value="WEDNESDAY">水曜日</option>
        <option value="THURSDAY">木曜日</option>
        <option value="FRIDAY">金曜日</option>
        <option value="SATURDAY">土曜日</option>
        <option value="SUNDAY">日曜日</option>
      </select>
    </div>

    <div class="form-group">
      <label>実行する時間帯 (時)</label>
      <select id="hour"></select>
      <div class="note">※Googleの仕様により、指定した時間の0分〜59分の間のどこかで実行されます。</div>
    </div>

    <button onclick="saveSettings()">設定を保存する</button>

    <script>
      const hourSelect = document.getElementById('hour');
      for (let i = 0; i < 24; i++) {
        const option = document.createElement('option');
        option.value = i;
        option.text = i + ':00 〜 ' + i + ':59';
        if (i === 9) option.selected = true;
        hourSelect.appendChild(option);
      }

      function toggleDaySelect() {
        const freq = document.getElementById('freq').value;
        document.getElementById('day-group').style.display = (freq === 'WEEKLY') ? 'block' : 'none';
      }

      function saveSettings() {
        const btn = document.querySelector('button');
        const originalText = btn.textContent;
        btn.disabled = true;
        btn.textContent = '設定中...';
        
        const config = { 
          freq: document.getElementById('freq').value, 
          day: document.getElementById('day').value,
          hour: document.getElementById('hour').value 
        };

        google.script.run
          .withSuccessHandler((msg) => { alert(msg); google.script.host.close(); })
          .withFailureHandler((err) => { alert('エラー: ' + err.message); btn.disabled = false; btn.textContent = originalText; })
          .setupTriggerFromClient(config);
      }
    </script>
  </body>
</html>`;
}

5. 管理用シートの作成

GASを保存後にGoogleスプレッドシートを更新するとメニューバーに 「⚡ Notion連携機能」 が表示されますので以下手順を実施してください。

1.メニューの「1. 初期セットアップ」 をクリックして下さい。


2.権限の承認画面が出ますので許可してください。


3.Notion APIキー の入力画面が表示されるので、控えていたAPIキーを貼り付けて下さい。

4.処理が終わると「管理コンソール」のシートが生成されます。

初期設定

作成したシート「管理コンソール」の項目を入力していきます。

1. DB情報の登録

生成されたシートを穴埋めして管理コンソールに、監視対象のDB情報を入力します。

  • DB名称: 自分がわかる名前(例: プロジェクト2025)
  • Notion DB URL: 対象データベースのURL

💡 Notion DBはフルページとして開いてからURLをコピーして下さい

2. 列情報を取得

メニューから[2.空欄の列設定の一括取得]をクリックして下さい。実行すると以下のようなプルダウンが出来るので期日や完了、追加ステータスの列を設定して下さい。

💡完了と超過ステータスは同じ列でも別の列でもどちらでも動作します。

3. 値情報を取得

D~F列をすべて穴埋めしたらメニューから[3.空欄の値設定の一括取得]をクリックして下さい。実行すると以下のようなプルダウンが出来るので完了時の値と超過時に設定する値を指定して下さい。

ツール実行

設定が完了したら、実際にツールを動かしてみましょう。

1.手動実行

メニューの 「4. 期限切れチェック実行」 をクリックします。 「実行ログ」列に「更新: 〇件」と表示されれば、正常に動作しています。Notion側でステータス変更と設定した通知が届くことを確認してください。

2.自動実行(の設定)

最後に、これを定期実行するようにします。 メニューの 「5. 自動実行スケジュール設定」 をクリックし、お好みの頻度設定を行ってください。

ツールの仕様

今回作成したGASツールの主な仕様は以下の通りです。

  • 期限切れチェックのロジック
    • 毎日(または指定日時)にNotionデータベースをチェックし、期日が「昨日以前(今日を含まない)」になっているタスクを抽出します。
  • ステータスの自動更新
    • 「未完了」かつ「期限切れ」のタスクが見つかった場合、そのステータスを自動で「指定した超過ステータス」に書き換えます。
  • 設定の自動化
    • GoogleスプレッドシートにNotionのデータベースURLを貼るだけで、必要なパラメータ(列名や選択肢)を自動で取得します。
  • シートの自動削除
    • 管理コンソール作成時に、空のシート(シート1など)があれば自動削除します。

まとめ

今回のツールは可能な限り「非エンジニア」でも扱えるように以下にこだわりました。

  • できるだけ標準機能(Notionインテグレーション)を活かす
  • コードを一切編集せずに利用できる
  • 設定はすべてスプレッドシート上(GUI)で完結させる
  • エラー時もメッセージやログを残してトラブルシュートしやすくする。

標準機能で手が届かない部分を、このツールで補完していただければ幸いです。

最後に

当方は副業情シスとして、中小企業様を中心に社内ITの最適化や、最近は非エンジニアに向けた生成AIの活用推進をご支援しております。

もし、「社内にIT担当者がいなくて困っている」「今のIT環境、もっと良くできるはず…」といったお悩みを抱える企業様がいらっしゃいましたら、ぜひお声がけください。下記サイトに詳細を記載しておりますので、ご興味をお持ちいただけましたら、問い合わせページよりお気軽にご連絡いただけますと幸いです。

Corporate-Engineer - inquiry/問い合わせ
ヤスムラ

◇基本情報
▶IT業界で働くアラフォー
▶一児の父

◇経歴/実績
▶2020年スタートアップに情シスとして転職
▶2021年に副業(情シス x ITコンサル)を開始して初年度売上100万円達成
▶2022年ITフリーランス向け副業コミュニティ(Slack)開設

ヤスムラをフォローする
GoogleNotion情シス自動化
スポンサーリンク
ヤスムラをフォローする
ITでお金を豊かにするブログ

コメント

タイトルとURLをコピーしました