
カレンダーの予定を稼働管理シートへ自動転記!Claude Code × clasp × GAS で作成〜デプロイまで
作りたいもの
こんにちは!コンサルティング部のヒスです。
案件でよくある「稼働時間の記録」。私は Google カレンダーに打ち合わせや作業の予定を入れているので、カレンダーの予定をそのまま稼働管理シート(スプレッドシート)に転記できたら楽だなと思っていました。
そこで今回は、Claude Code(AI)と対話しながら Google Apps Script(以下 GAS)のツールを作り、さらに clasp を使ってコードの配布(デプロイ)まで Claude Code のスラッシュコマンドで完結させてみました。
できあがったのはこんなツールです 👇
- スプレッドシートのメニュー「稼働管理」から、自分のカレンダーの予定をワンクリックで取り込む
- 取り込むのは 作業日 / 作業時間 / 会議名 / 担当者名
- 期間指定(例: 7/16以降だけ)や、入力データの一括クリアもメニューから
- コードの修正・配布は Claude Code の
/keyword/pushコマンドで完結(コピペ不要)
【完成イメージ】

なぜ「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.json…claspが「どのスクリプトへ 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 にします。
- https://script.google.com/home/usersettings を開く
- 「Google Apps Script API」を オン

💡 メンバー全員がこれをやる必要はありません。コードを配布するオーナー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を実行し、



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';
}
【警告イメージ】

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:メニューから取り込む(メンバー全員・設定不要)
あとはスプレッドシートを開いて、メニュー「稼働管理」から実行するだけ。
- 【Import】自分のカレンダーを取り込む … 担当者名を入力 → 全期間を取込



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




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




まとめ
- 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` を控え、結果を報告する。










