カレンダーの予定を稼働管理シートへ自動転記!Claude Code × clasp × GAS で作成〜デプロイまで

カレンダーの予定を稼働管理シートへ自動転記!Claude Code × clasp × GAS で作成〜デプロイまで

Google カレンダーの予定を稼働管理シートに自動転記するツールを、Claude Code と clasp を組み合わせて作りました。 コードの修正・配布は AI に寄せて、メンバーの運用はシンプルに。属人化しがちな GAS が、スラッシュコマンドで誰でも同じクオリティで扱えるようになります。
2026.07.17

作りたいもの

こんにちは!コンサルティング部のヒスです。

案件でよくある「稼働時間の記録」。私は Google カレンダーに打ち合わせや作業の予定を入れているので、カレンダーの予定をそのまま稼働管理シート(スプレッドシート)に転記できたら楽だなと思っていました。

そこで今回は、Claude Code(AI)と対話しながら Google Apps Script(以下 GAS)のツールを作り、さらに clasp を使ってコードの配布(デプロイ)まで Claude Code のスラッシュコマンドで完結させてみました。

できあがったのはこんなツールです 👇

  • スプレッドシートのメニュー「稼働管理」から、自分のカレンダーの予定をワンクリックで取り込む
  • 取り込むのは 作業日 / 作業時間 / 会議名 / 担当者名
  • 期間指定(例: 7/16以降だけ)や、入力データの一括クリアもメニューから
  • コードの修正・配布は Claude Code の /keyword /push コマンドで完結(コピペ不要)

【完成イメージ】

スクリーンショット 2026-07-17 17.09.03


なぜ「AI(Claude Code)で作る」のか

GAS は普通、スプレッドシートの「拡張機能 → Apps Script」にコードを手でコピペして使います。
ですが今回は次の理由から、コードの管理と配布を Claude Code 側に寄せました

① 修正・調整が「日本語で頼むだけ」で終わる

  • コードの修正や検索キーワードの変更を AI に指示するだけで反映できる(例:「取り込む列に"備考"も足して」)
  • GAS の文法を覚えていなくても、やりたいことを言語化できればカスタマイズできる
  • 手でのコピペミスをなくせる(clasp で自動デプロイ)

② プロジェクトごとに、チームで使い回せる

  • .claude/ フォルダを新しい案件へコピー → /keyword でキーワードを変える → /init-script即立ち上げ。案件が変わるたびに作り直す必要がない
  • 配布はオーナー1人が /push するだけ。シートを共有しているメンバー全員に自動で反映されるので、各メンバーは設定ゼロ
  • 「誰か1人しか直せない」属人化を避けられる。コードの直し方が自然文の指示なので、引き継ぎもラク

③ "手順そのもの" がドキュメントとして残る

  • スラッシュコマンド(.claude/commands/*.md)は、やりたい作業を自然文で書いた手順書でもある。読めば「何をするコマンドか」がすぐ分かる
  • 「なぜこう直したか」を AI と相談しながら進めるので、設計の意図が会話として残る

④ 役割分担がきれいに分かれる

  • 取り込みの実行」は、各メンバーが自分のカレンダーで動かす必要があるので、スプレッドシートのメニューに残す
  • 「コードの管理・配布」は Claude Code(CLI)に寄せる

つまり 「作る・直す・配る」はAIに寄せて、「使う」はメンバーの手元に残す、という役割分担です。

やること どこで 誰が
コードの修正・配布 Claude Code(CLI) オーナー(1人)
稼働の取り込み スプレッドシートのメニュー 各メンバー(設定不要)

構成要素

  • Google スプレッドシート(稼働管理シート/ネイティブ Google シート)
  • Google Apps Script(シートに紐づく bound スクリプト)
  • clasp(GAS を CLI から操作する Google 公式ツール)
  • Claude Code(AI。コード作成・スラッシュコマンドでの配布)

全体の流れ 👇

[Claude Code] --(/push)--> [clasp] --(デプロイ)--> [Apps Script] <--(bound)--> [スプレッドシート]
AIでコード編集                                    onOpenでメニュー生成         メンバーがメニュー実行

事前準備(この記事の前提)

手順に入る前に、「何をインストールするか」「フォルダに何を置くか」「誰が何をやるか」を先に整理しておきます。
ここを押さえておくと、以降の STEP が一気にわかりやすくなります。

必要なもの(インストール)

ツール 用途 必要な人
Google アカウント スプレッドシート/カレンダー 全員
Node.js(npm または pnpm) clasp を動かす実行環境 オーナーのみ
@google/clasp GAS を CLI から操作する公式ツール オーナーのみ
Claude Code コード作成・スラッシュコマンド実行 オーナーのみ

💡 取り込みを実行するだけのメンバーは、ブラウザと Google アカウントがあればOK。clasp も Claude Code も Node.js も、インストールは一切不要です。

プロジェクトフォルダの構成

Claude Code を起動するフォルダ(案件フォルダ)に、次のように .claude/ を置きます。

案件フォルダ/
├── .claude/
│   ├── commands/              ... 自作スラッシュコマンド(Markdown)
│   │   ├── keyword.md
│   │   ├── push.md
│   │   └── init-script.md
│   └── scripts/
│       ├── sync_hours.gs      ... GAS本体(このコードを配布する)
│       └── .clasp.json        ... 対象スクリプトの scriptId(/init-script が自動生成)
└── 稼働管理シート(.gsheet)    ... ネイティブ Google シート
  • .claude/commands/*.md … STEP 2 で紹介する自作スラッシュコマンドの定義。
  • .claude/scripts/sync_hours.gs … STEP 3 の GAS 本体。配布する実体はこのファイルです。
  • .claude/scripts/.clasp.jsonclasp が「どのスクリプトへ push するか」を示すファイル。/init-script 実行時に自動生成されるので、最初から自分で用意する必要はありません。
    • 新しい案件へ .claude/ を使い回すときは、この .clasp.json だけはコピーしないでください(案件ごとに /init-script で作り直します。詳しくは STEP 4 で)。

役割分担(オーナーとメンバー)

オーナー(1人) メンバー(全員)
事前インストール Node.js / clasp / Claude Code 不要
初回セットアップ clasp login + Apps Script API を ON(1回だけ) 不要
ふだんやること コードの修正・配布(/keyword /push /init-script シートのメニューから取り込むだけ

💡 一度オーナーがデプロイすれば、bound スクリプトはシートを共有している全員に共有されます。メンバー側は設定ゼロでメニューを使えます。


STEP 1:clasp の準備(オーナーのみ・1回だけ)

まずは clasp をインストールしてログインします。

# インストール(npm でも可)
pnpm add -g @google/clasp

# Google アカウントでログイン(ブラウザが開きます)
clasp login

さらに、Apps Script API を ON にします。

💡 メンバー全員がこれをやる必要はありません。コードを配布するオーナー1人だけでOKです。
取り込みを実行するだけのメンバーは、clasp もログインも不要です。


STEP 2:Claude Code にワークフローを「教える」(スラッシュコマンド)

Claude Code では、.claude/commands/ に Markdown を置くと自作のスラッシュコマンドになります。今回は次の3つを用意しました。

  • /keyword <検索語> … Googleカレンダーの予定を取り込む際の検索キーワードを変更
  • /push … ローカルの .gs を Apps Script へデプロイ
  • /init-script … 新しいシートにスクリプトを新規作成+初回デプロイ

たとえば /push コマンドの中身(抜粋)はこんな感じです。「AIにこの手順で動いてね」と自然言語で指示を書いておくイメージです。

---
description: sync_hours.gs を clasp で Apps Script にデプロイする
allowed-tools: Bash, Read
---

# /push — sync_hours.gs を Apps Script へデプロイする

## 手順
1. `.claude/scripts/.clasp.json` の存在を確認(無ければ中断)
2. 送信対象を確認: `clasp --project .claude/scripts status`
3. デプロイ: `clasp --project .claude/scripts push --force`
4. 結果を報告する

📸 スクリーンショット④(Claude Code 画面)
Claude Code で /keyword プロジェクトX/push を実行し、

スクリーンショット 2026-07-17 17.42.45

スクリーンショット 2026-07-17 17.51.28

スクリーンショット 2026-07-17 17.58.12


STEP 3:GAS 本体のコード(sync_hours.gs)

コードのポイントだけ紹介します(全文は長いので抜粋)。

設定と、メニューの生成

onOpen() でスプレッドシートに「稼働管理」メニューを作ります。項目は3つ。

// ===== 設定 =====
var KEYWORD = 'プロジェクトX';   // タイトルにこの語を含む予定だけ取り込む(案件ごとに変更)
var CALENDAR_ID = null;          // null = 実行者本人のメインカレンダー
var SPREADSHEET_ID = '<対象シートの文書ID>';

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('稼働管理')
    .addItem('【Import】自分のカレンダーを取り込む', 'importCalendarHours')
    .addItem('【Import (Range)】期間を指定して取り込む', 'importCalendarHoursRange')
    .addItem('【Clear】取り込みデータをクリア', 'clearImportedData')
    .addToUi();
}

「実行した人のカレンダー」を読むのがポイント

CalendarApp.getDefaultCalendar()スクリプトを実行した本人のカレンダーを返します。
つまり各メンバーが自分でメニューを押せば、自分の予定が自分の名前で取り込まれます。

// 取込の中核(メニュー/CLI 共通)。担当者名と任意の期間を受け取る。
function runImport_(tantou, fromRaw, toRaw) {
  // 検索キーワード未設定ガード(後述)
  if (!keywordConfigured_()) {
    return 'エラー: 検索キーワードが未設定です。設定してから実行してください。';
  }
  var ss = SpreadsheetApp.openById(SPREADSHEET_ID);
  var tz = ss.getSpreadsheetTimeZone();
  var cal = CALENDAR_ID ? CalendarApp.getCalendarById(CALENDAR_ID)
                        : CalendarApp.getDefaultCalendar();
  // ... 月シート(YYYYMM)ごとに、KEYWORD を含む時間指定予定を取得して行追加 ...
}

重複防止

同じ予定を二重に登録しないよう、「作業日 + 会議名 + 担当者」をキーにして、
すでにある行はスキップします。何度実行しても増殖しません。

// 同一性キー = 作業日(yyyy-MM-dd) + 会議名 + 担当者
function rowKey_(dateObj, title, tantou, tz) {
  var ds = dateObj ? Utilities.formatDate(dateObj, tz, 'yyyy-MM-dd') : '';
  return ds + String(title || '').trim() + String(tantou || '').trim();
}

うっかり事故を防ぐ「キーワード未設定ガード」

新しい案件にコピーして使うとき、キーワードを設定し忘れると、意図しない予定を取り込んでしまいます。
そこで KEYWORD が未設定('xxxxx' や空)のときは、メニューを押しても警告を出して中断するようにしました。

function keywordConfigured_() {
  var k = (KEYWORD == null ? '' : String(KEYWORD)).trim();
  return k !== '' && k.toLowerCase() !== 'xxxxx';
}

【警告イメージ】

スクリーンショット 2026-07-17 18.07.49


STEP 4:/init-script でシートにデプロイ

新しいスプレッドシートに対しては、Claude Code で /init-script と打つだけ。
フォルダ内の「稼働管理」シートを自動で見つけ、確認の上でbound スクリプトを新規作成+デプロイします。

内部的には clasp の以下を実行しています。

# 既存シートに bound スクリプトを作成(--type は付けない!)
clasp create-script --title "稼働管理" --parentId "<シートの文書ID>" --rootDir .
# コードをデプロイ
clasp --project .claude/scripts push --force

STEP 5:メニューから取り込む(メンバー全員・設定不要)

あとはスプレッドシートを開いて、メニュー「稼働管理」から実行するだけ。

  1. 【Import】自分のカレンダーを取り込む … 担当者名を入力 → 全期間を取込

スクリーンショット 2026-07-17 18.18.10

スクリーンショット 2026-07-17 18.19.12

スクリーンショット 2026-07-17 18.19.32


  1. 【Import (Range)】期間を指定して取り込む … 「2026/07/22」や「2026/07/22~2026/07/31」で期間指定

スクリーンショット 2026-07-17 18.22.19

スクリーンショット 2026-07-17 18.22.30

スクリーンショット 2026-07-17 18.22.53

スクリーンショット 2026-07-17 18.23.09


  1. 【Clear】取り込みデータをクリア … その月シートの入力を確認の上で一括消去

スクリーンショット 2026-07-17 18.26.44

スクリーンショット 2026-07-17 18.26.54

スクリーンショット 2026-07-17 18.27.09

スクリーンショット 2026-07-17 18.27.18


まとめ

  • Google カレンダー → 稼働管理シートへの取り込みツールを、Claude Code と対話しながら作成しました。
  • コードの配布は clasp × スラッシュコマンド(/push /init-script)でCLI完結取り込みはメンバーがメニューでワンクリック、という役割分担にしました。
  • 重複防止・キーワード未設定ガード・期間指定・一括クリアなど、運用で困りそうなところを先回りでAIに相談しながら盛り込めました。

Claude Code と clasp を組み合わせることで、コードの修正・配布・運用がすべて自然文の指示で完結するようになりました。
特に良かったのは、プロジェクトごとにキーワードを変えるだけで、複数案件のメンバーが同じ仕組みをそのまま使い回せる点です。

属人化しがちな「あの人しか触れないGASコード」が、/keyword のようなスラッシュコマンドに集約されたことで、誰が対応しても同じ手順・同じ品質でメンテできるようになりました。
コマンドという共通言語があるので、引き継ぎやチーム間の運用統一もぐっとラクになった実感があります。

同じように「カレンダー→シート」で消耗している方の参考になれば嬉しいです。
最後までお読みいただきありがとうございました!


付録A:GAS 全文(sync_hours.gs

そのままコピーして使えるよう、全文を載せておきます。
SPREADSHEET_ID だけは自分の稼働管理シートの文書ID(シートURLの /d/<ここ>/edit)に差し替えてください。
KEYWORD/keyword で設定するか、直接この値を書き換えてもOKです。

/**
 * 稼働管理(Google スプレッドシート)へ「実行者本人のカレンダー」予定を反映する Apps Script。
 *
 * 動作モデル(複数メンバー運用):
 *  - 実行した本人のカレンダーから、タイトルに KEYWORD を含む予定を取得する。
 *  - シート名が YYYYMM(例 202607)のシートに、その月の予定を行追加する。
 *    A=作業日 / C=作業時間 / E=作業内容(会議名) / F=担当(実行時に入力した氏名)。
 *  - 各行の同一性キー = (作業日 + 会議名 + 担当者)。
 *      * 既に同じキーの行があれば追加しない(再実行しても重複しない)。
 *      * 手で行を消せば、次の実行で再び取り込まれる。
 *      * C(作業時間)や B/D/G を手修正しても、キーが変わらなければ保持される。
 *  - 追加後、入力域を「作業日の昇順」で並べ替える。同じ日付なら既存行が上、
 *    後から実行したメンバーの行が下に来る(実行順を保持)。
 *  - 書き込むのは A/C/E/F のみ。B(種別)/D(請求時間)/G(備考) は手入力用に触らない。
 *
 * 使い方:
 *  1. 対象の Google スプレッドシートを開く
 *  2. 拡張機能 → Apps Script に貼り付けて保存
 *  3. 再読込 → メニュー「稼働管理」→「自分のカレンダーを取り込む」
 *  4. 初回は権限承認。担当者名の入力ダイアログに氏名を入力
 *  → 別メンバーが開いて実行すれば、その人のカレンダー+その人の氏名で行が追加される。
 */

// ===== 設定 =====
// KEYWORD: タイトルにこの語を含む予定だけ取り込む。案件ごとに設定する。
// 'xxxxx'(未設定の目印)や空のままだと、実行時に「キーワード未設定」の警告を出して中断する(安全ガード)。
var KEYWORD = 'プロジェクトX';
var CALENDAR_ID = null;                     // null = 実行者のメインカレンダー。共有カレンダーなら ID 指定
// 対象スプレッドシートの文書ID。CLI(clasp run)は UI 文脈が無く getActiveSpreadsheet() が使えないため必須。
// メニュー実行時もこの ID のシートを開く(同じシートなので結果は同じ)。null ならアクティブシート。
var SPREADSHEET_ID = '<対象シートの文書ID>';
var HEADER_LABEL = '作業日';                 // A列ヘッダ
var TOTAL_LABELS = ['合計', '総計'];         // 入力域の終端マーカー(A列)
// 列番号: A=1 作業日 / B=2 種別 / C=3 作業時間 / D=4 請求時間 / E=5 作業内容 / F=6 担当 / G=7 備考
var COL_A = 1, COL_C = 3, COL_E = 5, COL_F = 6, COL_G = 7;
var NAME_PROP = 'sync_hours_tantou_name';   // 担当者名を利用者ごとに記憶するキー

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('稼働管理')
    .addItem('【Import】自分のカレンダーを取り込む', 'importCalendarHours')
    .addItem('【Import (Range)】期間を指定して取り込む', 'importCalendarHoursRange')
    .addItem('【Clear】取り込みデータをクリア', 'clearImportedData')
    .addToUi();
}

/** KEYWORD が実際に設定されているか('xxxxx' や空でないか)。 */
function keywordConfigured_() {
  var k = (KEYWORD == null ? '' : String(KEYWORD)).trim();
  return k !== '' && k.toLowerCase() !== 'xxxxx';
}

/** 日付入力文字列を Date(00:00) に変換。'YYYY/MM/DD' / 'MM/DD'(区切り / - .)対応。
 *  年を省略した場合は fallbackYear を使う。空や無効なら null。 */
function parseDateInput_(str, fallbackYear) {
  if (!str) return null;
  var m = String(str).trim().match(/^(?:(\d{4})[\/\-.])?(\d{1,2})[\/\-.](\d{1,2})$/);
  if (!m) return null;
  var y = m[1] ? parseInt(m[1], 10) : fallbackYear;
  return new Date(y, parseInt(m[2], 10) - 1, parseInt(m[3], 10));
}

// メニュー①「自分のカレンダーを取り込む」用(全期間)。
function importCalendarHours() {
  var ui = SpreadsheetApp.getUi();
  if (!guardKeyword_(ui)) return;
  var tantou = promptTantou_(ui);
  if (!tantou) return;
  var result = runImport_(tantou, '', '');
  ui.alert('稼働管理 取り込み結果', result, ui.ButtonSet.OK);
}

// メニュー③「期間を指定して取り込む」用。担当者名の後に取り込み期間を入力させる。
function importCalendarHoursRange() {
  var ui = SpreadsheetApp.getUi();
  if (!guardKeyword_(ui)) return;
  var tantou = promptTantou_(ui);
  if (!tantou) return;
  var range = promptRange_(ui);
  if (!range) return;
  var result = runImport_(tantou, range.fromRaw, range.toRaw);
  var header = range.rangeStr
    ? ('期間: ' + (range.fromRaw || '最初') + ' 〜 ' + (range.toRaw || '最後') + '\n\n') : '';
  ui.alert('稼働管理 取り込み結果', header + result, ui.ButtonSet.OK);
}

/** 検索キーワード未設定ガード。未設定なら警告して false を返す。 */
function guardKeyword_(ui) {
  if (keywordConfigured_()) return true;
  ui.alert('検索キーワードが未設定です',
    '取り込む予定を絞り込む「検索キーワード」が設定されていません(現在: ' + KEYWORD + ')。\n' +
    '管理者がキーワードを設定するまで取り込みできません。', ui.ButtonSet.OK);
  return false;
}

/** 担当者名を尋ねて返す(前回値を記憶)。キャンセル/未入力なら null。 */
function promptTantou_(ui) {
  var userProps = PropertiesService.getUserProperties();
  var prevName = userProps.getProperty(NAME_PROP) || '';
  var resp = ui.prompt('担当者名の入力',
    '稼働管理の「担当」列(F)に記入する氏名を入力してください。' +
    (prevName ? '\n前回: ' + prevName : ''),
    ui.ButtonSet.OK_CANCEL);
  if (resp.getSelectedButton() !== ui.Button.OK) return null;
  var tantou = (resp.getResponseText() || '').trim();
  if (!tantou) tantou = prevName;
  if (!tantou) { ui.alert('担当者名が未入力のため中止しました。'); return null; }
  userProps.setProperty(NAME_PROP, tantou);
  return tantou;
}

/** 取り込み期間を尋ねて {rangeStr, fromRaw, toRaw} を返す。キャンセル/形式不正なら null(空欄=全期間はOK)。 */
function promptRange_(ui) {
  var resp = ui.prompt('取り込み期間',
    '取り込む期間を入力してください。空欄なら全期間。\n' +
    '例: 「2026/07/16」= その日以降すべて / 「2026/07/16~2026/07/31」= 範囲指定。\n' +
    '月/日だけ(例 7/16)でも可(年はシートに合わせます)。',
    ui.ButtonSet.OK_CANCEL);
  if (resp.getSelectedButton() !== ui.Button.OK) return null;
  var rangeStr = (resp.getResponseText() || '').trim();
  var fromRaw = '', toRaw = '';
  if (rangeStr) {
    var parts = rangeStr.split(/\s*[~〜~]\s*|\s+-\s+|\s*から\s*/);
    fromRaw = (parts[0] || '').trim();
    toRaw = (parts[1] || '').trim();
    var DATE_RE = /^(?:\d{4}[\/\-.])?\d{1,2}[\/\-.]\d{1,2}$/;
    if (fromRaw && !DATE_RE.test(fromRaw)) { ui.alert('日付の形式が不正です: ' + fromRaw + '\n例: 2026/07/16 または 7/16'); return null; }
    if (toRaw && !DATE_RE.test(toRaw)) { ui.alert('日付の形式が不正です: ' + toRaw + '\n例: 2026/07/31 または 7/31'); return null; }
  }
  return { rangeStr: rangeStr, fromRaw: fromRaw, toRaw: toRaw };
}

// メニュー「稼働管理 → 取り込みデータをクリア」用。
// 現在開いている月シート(YYYYMM)の入力域(A〜G)を、確認の上で一括消去する。
// ヘッダ・合計行・書式は残す(値のみ削除)。元に戻せない破壊的操作なので必ず確認する。
function clearImportedData() {
  var ui = SpreadsheetApp.getUi();
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var name = sheet.getName();

  // 月シート(YYYYMM)以外では実行しない
  if (!/^\d{6}$/.test(name)) {
    ui.alert('クリアできません',
      '月シート(名前が YYYYMM 形式、例: 202607)を開いた状態で実行してください。\n現在のシート: ' + name,
      ui.ButtonSet.OK);
    return;
  }

  var region = findDataRegion_(sheet);
  if (!region) {
    ui.alert('クリアできません',
      'このシートに「作業日」ヘッダが見つからず、入力域を判定できません。', ui.ButtonSet.OK);
    return;
  }

  // 入力域(A〜G)の実データ行数を数える
  var capacity = region.end - region.start + 1;
  var block = sheet.getRange(region.start, 1, capacity, COL_G).getValues();
  var rows = 0;
  for (var i = 0; i < block.length; i++) { if (rowHasData_(block[i])) rows++; }

  if (rows === 0) {
    ui.alert('稼働管理', name + ' の入力域は既に空です。', ui.ButtonSet.OK);
    return;
  }

  // 確認(破壊的操作)
  var resp = ui.alert('取り込みデータをクリア',
    '「' + name + '」の入力データ ' + rows + ' 行(A〜G列すべて)を消去します。\n' +
    'この操作は元に戻せません。よろしいですか?', ui.ButtonSet.OK_CANCEL);
  if (resp !== ui.Button.OK) return;

  // 入力域(A〜G)をクリア(ヘッダ・合計行・書式は残る)
  sheet.getRange(region.start, 1, capacity, COL_G).clearContent();
  ui.alert('稼働管理', name + ' の入力データ ' + rows + ' 行を消去しました。', ui.ButtonSet.OK);
}

/**
 * CLI コマンド「clasp run」用のエントリ。
 * UI を使わず担当者名を引数で受け取り、結果サマリ文字列を返す(それが CLI に返る)。
 * 実行者本人(clasp login したアカウント)のカレンダーを読む。
 * fromRaw/toRaw は任意('YYYY/MM/DD' 等)。省略時は全期間。
 */
function syncHoursForName(tantou, fromRaw, toRaw) {
  tantou = (tantou == null ? '' : String(tantou)).trim();
  if (!tantou) return 'エラー: 担当者名が空です。「<担当者名>」の形式で指定してください。';
  return runImport_(tantou, fromRaw || '', toRaw || '');
}

/** 取込の中核。担当者名と任意の期間(fromRaw/toRaw)を受け取り結果サマリ文字列を返す(UI 非依存。メニュー/CLI 共通)。 */
function runImport_(tantou, fromRaw, toRaw) {
  if (!keywordConfigured_()) {
    return 'エラー: 検索キーワードが未設定です(現在: ' + KEYWORD + ')。設定してから実行してください。';
  }
  var ss = SPREADSHEET_ID ? SpreadsheetApp.openById(SPREADSHEET_ID)
                          : SpreadsheetApp.getActiveSpreadsheet();
  var tz = ss.getSpreadsheetTimeZone();

  var cal = CALENDAR_ID ? CalendarApp.getCalendarById(CALENDAR_ID)
                        : CalendarApp.getDefaultCalendar();

  var lines = [];
  var sheets = ss.getSheets();

  for (var s = 0; s < sheets.length; s++) {
    var sheet = sheets[s];
    var name = sheet.getName();
    var mm = name.match(/^(\d{4})(\d{2})$/);
    if (!mm) continue;                                  // 月シート以外はスキップ
    var year = parseInt(mm[1], 10);
    var month = parseInt(mm[2], 10);                    // 1-12
    var fromBound = parseDateInput_(fromRaw, year);     // 取り込み下限(この日以降)。null=下限なし
    var toBound = parseDateInput_(toRaw, year);         // 取り込み上限(この日まで含む)。null=上限なし

    var region = findDataRegion_(sheet);
    if (!region) { lines.push(name + ': ヘッダ「作業日」が無く判定不可 → スキップ'); continue; }

    // --- 既存行の読み込み(A〜G を丸ごと保持) ---
    var capacity = region.end - region.start + 1;
    var block = sheet.getRange(region.start, 1, capacity, COL_G).getValues();
    var existing = [];
    var existingKeys = {};
    for (var i = 0; i < block.length; i++) {
      var row = block[i];
      if (!rowHasData_(row)) continue;
      var d0 = toDate_(row[COL_A - 1], tz);
      var key = rowKey_(d0, row[COL_E - 1], row[COL_F - 1], tz);
      existingKeys[key] = true;
      existing.push({
        vals: normalizeRow_(row),
        t: d0 ? d0.getTime() : Number.MAX_VALUE,
        o: i                                            // 既存の並び順を保持
      });
    }

    // --- 実行者カレンダーから当月の対象予定を取得 ---
    var start = new Date(year, month - 1, 1, 0, 0, 0);
    var end = new Date(year, month, 1, 0, 0, 0);
    var events = cal.getEvents(start, end);

    var added = [];
    var addedKeys = {};
    var skippedAllDay = 0, skippedDup = 0;
    for (var e = 0; e < events.length; e++) {
      var ev = events[e];
      var title = (ev.getTitle() || '').trim();
      if (title.toLowerCase().indexOf(KEYWORD.toLowerCase()) === -1) continue;
      if (ev.isAllDayEvent()) { skippedAllDay++; continue; }
      var st = ev.getStartTime();
      if (st.getMonth() !== (month - 1) || st.getFullYear() !== year) continue;
      var evDay = new Date(st.getFullYear(), st.getMonth(), st.getDate());
      if (fromBound && evDay.getTime() < fromBound.getTime()) continue;   // 開始日より前は除外
      if (toBound && evDay.getTime() > toBound.getTime()) continue;       // 終了日より後は除外(終了日は含む)
      var hours = (ev.getEndTime().getTime() - st.getTime()) / 3600000;

      var key = rowKey_(st, title, tantou, tz);
      if (existingKeys[key] || addedKeys[key]) { skippedDup++; continue; }
      addedKeys[key] = true;

      var v = ['', '', '', '', '', '', ''];             // A〜G
      v[COL_A - 1] = new Date(st.getFullYear(), st.getMonth(), st.getDate()); // 作業日(時刻なし)
      v[COL_C - 1] = hours;                             // 作業時間
      v[COL_E - 1] = title;                             // 作業内容=会議名
      v[COL_F - 1] = tantou;                            // 担当
      added.push({ vals: v, t: v[COL_A - 1].getTime(), o: existing.length + added.length + 1e6 });
    }

    if (added.length === 0) {
      lines.push(name + ': 追加0件(対象予定なし/全て取込済み'
        + (skippedDup ? ' 重複' + skippedDup + '件' : '')
        + (skippedAllDay ? ' 終日' + skippedAllDay + '件除外' : '') + ')');
      continue;
    }

    // --- 既存+新規をマージし、作業日昇順(同日は既存→新規)で並べ替え ---
    var merged = existing.concat(added);
    merged.sort(function (a, b) { return (a.t - b.t) || (a.o - b.o); });

    // --- 容量が足りなければ合計行の手前に行を挿入(SUM範囲は自動拡張) ---
    if (merged.length > capacity) {
      var extra = merged.length - capacity;
      sheet.insertRowsBefore(region.end, extra);        // 最終データ行の手前に挿入=範囲内
      region = findDataRegion_(sheet);
      capacity = region.end - region.start + 1;
    }

    // --- 入力域(A〜G)をクリアして並べ替え後の内容を書き戻し ---
    sheet.getRange(region.start, 1, capacity, COL_G).clearContent();
    var out = merged.map(function (r) { return r.vals; });
    sheet.getRange(region.start, 1, out.length, COL_G).setValues(out);
    sheet.getRange(region.start, COL_A, out.length, 1).setNumberFormat('yyyy/m/d');
    sheet.getRange(region.start, COL_C, out.length, 1).setNumberFormat('0.00');

    var msg = name + ': ' + added.length + '件 追加(担当: ' + tantou + ')';
    if (skippedDup) msg += ' / 取込済スキップ ' + skippedDup + '件';
    if (skippedAllDay) msg += ' / 終日除外 ' + skippedAllDay + '件';
    lines.push(msg);
  }

  if (lines.length === 0) lines.push('対象の月シート(YYYYMM)が見つかりませんでした。');
  return lines.join('\n');
}

/** A列ヘッダ「作業日」を探し、入力域 {start, end} を返す。無ければ null。 */
function findDataRegion_(sheet) {
  var last = sheet.getLastRow();
  if (last < 1) return null;
  var colA = sheet.getRange(1, COL_A, last, 1).getValues();
  var header = -1;
  for (var i = 0; i < colA.length; i++) {
    var v = colA[i][0];
    if (typeof v === 'string' && v.trim() === HEADER_LABEL) { header = i + 1; break; }
  }
  if (header === -1) return null;
  var dataStart = header + 1;
  var dataEnd = -1;
  for (var k = dataStart - 1; k < colA.length; k++) {
    var vv = colA[k][0];
    if (typeof vv === 'string' && TOTAL_LABELS.indexOf(vv.trim()) !== -1) { dataEnd = k; break; } // k=行index-1 → 合計の1つ上
  }
  if (dataEnd === -1) dataEnd = header + 200;   // 合計行が無いテンプレは余裕を持たせる
  if (dataEnd < dataStart) return null;
  return { start: dataStart, end: dataEnd };
}

/** 行(A〜G配列)に 1 セルでも実データがあれば true。 */
function rowHasData_(row) {
  for (var c = 0; c < COL_G; c++) {
    var v = row[c];
    if (v !== '' && v !== null && v !== undefined) return true;
  }
  return false;
}

/** セル値を Date に変換(不可なら null)。 */
function toDate_(v, tz) {
  if (v instanceof Date) return v;
  if (typeof v === 'string' && v.trim() !== '') {
    var d = new Date(v.trim());
    if (!isNaN(d.getTime())) return d;
  }
  return null;
}

/** 同一性キー = 作業日(yyyy-MM-dd) + 会議名 + 担当者。 */
function rowKey_(dateObj, title, tantou, tz) {
  var ds = dateObj ? Utilities.formatDate(dateObj, tz, 'yyyy-MM-dd') : '';
  return ds + '' + String(title || '').trim() + '' + String(tantou || '').trim();
}

/** setValues 用に null/undefined を '' に正規化(Date はそのまま)。 */
function normalizeRow_(row) {
  var out = [];
  for (var c = 0; c < COL_G; c++) {
    var v = row[c];
    out.push((v === null || v === undefined) ? '' : v);
  }
  return out;
}

付録B:スラッシュコマンドの定義(.claude/commands/

「AIにこの手順で動いてね」と自然文で書いた手順書が、そのままスラッシュコマンドになります。
本文で触れた3つのコマンドの定義(Markdown)を載せておきます。ここも SPREADSHEET_ID やキーワード例は
ダミーに置き換えてあります。

/keyword — 検索キーワードを変更

---
description: 稼働時間取込スクリプト(sync_hours.gs)の検索キーワード KEYWORD を変更する
argument-hint: "<検索キーワード>(例: プロジェクトX)"
allowed-tools: Read, Edit, Bash
---

# /keyword — sync_hours.gs の KEYWORD を変更する

引数(新しい検索キーワード): `$ARGUMENTS`

対象ファイル: `.claude/scripts/sync_hours.gs` の行 `var KEYWORD = '……';`

## 手順
1. まず現在値を確認する: `grep -n "var KEYWORD" .claude/scripts/sync_hours.gs`
2. **引数が空の場合**は、現在の KEYWORD を表示するだけで**変更せず終了**する。
3. 引数がある場合は、前後の空白を除いた値を新キーワードとする(`'``\'` にエスケープ)。
4. `Edit``var KEYWORD = '<旧値>';``var KEYWORD = '<新値>';` に置き換える(この1行だけ)。
5. 変更前 → 変更後を報告する。
6. **必ず**「ローカルの `.gs` を書き換えただけなので、反映には `/push` が必要」と伝える。

/push — Apps Script へデプロイ

---
description: ローカルの sync_hours.gs を clasp で対象スプレッドシートの Apps Script に反映(push)する
allowed-tools: Bash, Read
---

# /push — sync_hours.gs を Apps Script へデプロイする

## 前提(初回のみ・未了ならユーザーに案内して中断)
- `clasp` インストール済み・`clasp login` 済み・Apps Script API = ON。
- `.claude/scripts/.clasp.json` が存在し、対象スクリプトの `scriptId` が入っている。

## 手順
1. 設定ファイルの存在を確認: `test -f .claude/scripts/.clasp.json`(無ければ中断)
2. 送信対象をドライラン確認: `clasp --project .claude/scripts status`
`sync_hours.gs``appsscript.json` の2つだけが対象か)
3. push を実行: `clasp --project .claude/scripts push --force`
4. 結果を報告し、「push はリモートをローカルの内容で置き換える/メンバーはシートを開き直すとメニューが最新になる」と伝える。

/init-script — 新しいシートに初回セットアップ

---
description: 空のスプレッドシートに稼働管理スクリプトを新規作成し、sync_hours.gs を初回デプロイする
argument-hint: "[シートの文書ID](省略時はフォルダ内の「稼働管理」シートを自動検出)"
allowed-tools: Bash, Read, Edit
---

# /init-script — 空のスプレッドシートに稼働管理スクリプトを新規セットアップする

`clasp create` で対象シートに**ひも付く(bound)** スクリプトを新規作成し(`.clasp.json` が自動生成)、
続けて `sync_hours.gs` をデプロイする。**新規案件の初回セットアップ専用。**

## 手順(要点)
1. 対象シートの文書IDを決める(引数 or フォルダ内の `*稼働管理*.gsheet` を自動検出して `doc_id` を取得)。
   検出/指定した内容を示して**実行前に必ず一度だけ確認**する。
2. bound スクリプトを作成(`--type sheets` は付けない=既存シートにひも付ける):
   `clasp create-script --title "稼働管理" --parentId "<シートの文書ID>" --rootDir .`
`Bound to document:` と出れば成功。
3. コードをデプロイ: `clasp --project .claude/scripts push --force`
4. `.clasp.json``scriptId` を控え、結果を報告する。

Claudeならクラスメソッドにお任せください

クラスメソッドは、Anthropic社とリセラー契約を締結しています。各種製品ガイドから、業種別の活用法、フェーズごとのお悩み解決などサービス支援ページにまとめております。まずはご覧いただき、お気軽にご相談ください。

サービス詳細を見る

この記事をシェアする

AI白書

関連記事