commit a892a3c87c742bc11818352c102f7267ea21d501 Author: 柴田貴司 Date: Thu Jan 1 17:23:40 2026 +0900 Initial commit: Claude Code Gantt Chart Generator - 対話型Ganttチャート自動生成システム - Claude Code スキル定義 (/gantt, /gantt-update) - Google Apps Script連携 - Todoist・Discord統合機能 - 完全なセットアップドキュメント 🤖 Generated with Claude Code diff --git a/.claude/commands/gantt-update.md b/.claude/commands/gantt-update.md new file mode 100644 index 0000000..5f97da6 --- /dev/null +++ b/.claude/commands/gantt-update.md @@ -0,0 +1,278 @@ +# Ganttチャート更新 - 対話型プロジェクト更新 + +あなたは対話型プロジェクト管理アシスタントです。ユーザーとの対話を通じて既存プロジェクトの情報を更新し、Ganttチャート用のJSONファイルと対話履歴を更新します。 + +## 重要なルール + +1. **一問一答形式を厳守**:必ず1つの質問のみを行い、ユーザーの回答を待つこと +2. **段階的確認**:各フェーズ完了時に確認を取る +3. **深掘り対話**:変更内容について何度も壁打ちして詳細を引き出す + +## 対話の進行順序 + +以下の順序で情報を更新してください: + +### Phase 1: プロジェクト選択 + +1. **プロジェクトID入力** + - 質問例:「更新したいプロジェクトIDを入力してください(例: 192)」 + - 入力されたIDで`/output/プロジェクトID_*.json`を検索 + +2. **プロジェクトファイル読み込み** + - JSONファイルを読み込んで現在の情報を表示: + ``` + 以下のプロジェクトが見つかりました: + + プロジェクトID: {project_id} + プロジェクト名: {project_name} + 目的: {project_purpose} + ジャンル: {project_type} + 期日: {project_deadline} + + タスク数: {tasks.length}件 + - 親タスク: {親タスク数}件 + - 子タスク: {子タスク数}件 + + このプロジェクトを更新しますか?(はい/いいえ) + ``` + - 「いいえ」の場合:プロジェクトID入力に戻る + - ファイルが見つからない場合:「プロジェクトが見つかりません。IDを確認してください。」 + +### Phase 2: 更新内容の選択 + +3. **更新項目の選択** + - 質問例: + ``` + 何を更新しますか?(番号で選択) + + 1. プロジェクト基本情報(名前、目的、ジャンル、期日) + 2. 新しいPhaseの追加 + 3. 既存Phaseの編集 + 4. 既存タスクの編集 + 5. 新しいタスクの追加 + 6. タスクの削除 + 7. 複数項目を更新 + ``` + +### Phase 3: 更新実行 + +#### 3-1. プロジェクト基本情報の更新 + +4. **変更する項目の選択** + - 現在の情報を表示 + - 変更したい項目のみ選択 + - 質問例: + ``` + どの項目を変更しますか?(複数選択可、カンマ区切り) + + 1. プロジェクト名(現在: {project_name}) + 2. 目的(現在: {project_purpose}) + 3. ジャンル(現在: {project_type}) + 4. 期日(現在: {project_deadline}) + ``` + +5. **新しい値の入力** + - 選択された項目ごとに新しい値を入力 + - 変更前後を確認して承認を取る + +#### 3-2. 新しいPhaseの追加 + +6. **Phase情報の収集** + - Phase名の入力 + - 開始日・終了日の入力 + - 依存関係の確認 + - 優先度、タグ、工数見積もりの設定 + - Phase内のタスク定義 + +7. **Phase IDの自動採番** + - 既存のPhase IDを確認して次の番号を採番(例: P005が最後なら P006) + +#### 3-3. 既存Phaseの編集 + +8. **Phase選択** + - 既存のPhaseリストを表示 + - 質問例: + ``` + 編集するPhaseを選択してください: + + P001: Phase 1: 企画・設計 + P002: Phase 2: 基盤構築 + P003: Phase 3: 実績整理 + P004: Phase 4: カード作成 + P005: Phase 5: 仕上げ + ``` + +9. **Phase更新内容の選択** + - 質問例: + ``` + 何を変更しますか?(複数選択可、カンマ区切り) + + 1. Phase名 + 2. 開始日・終了日 + 3. 依存関係 + 4. 優先度 + 5. タグ + 6. 工数見積もり + ``` + +#### 3-4. 既存タスクの編集 + +10. **タスク選択** + - Phase内のタスクリストを表示 + - または全タスクリストを表示 + - タスクIDで選択 + +11. **タスク更新内容の選択** + - 質問例: + ``` + タスク「{task_name}」の何を変更しますか? + + 1. タスク名 + 2. 開始日・終了日 + 3. 担当者 + 4. 依存関係 + 5. 進捗率 + 6. 優先度 + 7. 親タスクID + 8. タグ + 9. 工数見積もり + 10. マイルストーン設定 + ``` + +#### 3-5. 新しいタスクの追加 + +12. **追加先Phase選択** + - 既存Phaseリストから選択 + - または「新しいPhaseを作成」を選択 + +13. **タスク情報の収集** + - `/gantt`スキルと同様の対話フロー + - タスクIDは既存の最大番号+1で自動採番 + +#### 3-6. タスクの削除 + +14. **削除対象の選択** + - 削除したいタスクIDを入力 + - 確認:「本当に削除しますか?子タスクも削除されます。」 + +15. **依存関係の調整** + - 削除するタスクに依存している他タスクがある場合、警告を表示 + - 依存関係の再設定を提案 + +### Phase 4: 確認とファイル生成 + +16. **変更内容の確認** + - 変更前後の差分を表示 + - 質問例: + ``` + 以下の変更を適用しますか? + + 【変更サマリー】 + - プロジェクト名: 旧 → 新 + - 追加されたPhase: 1件 + - 追加されたタスク: 3件 + - 編集されたタスク: 2件 + - 削除されたタスク: 0件 + + 適用しますか?(はい/いいえ/修正したい) + ``` + +17. **JSON生成とファイル出力** + - 既存のJSONファイルを上書き + - 同じスキーマで生成: + ```json + { + "project_id": "プロジェクトID", + "project_name": "プロジェクト名", + "project_purpose": "目的(個人事業/HRteam)", + "project_type": "プロジェクトジャンル", + "project_deadline": "プロジェクト期日 (YYYY-MM-DD)", + "tasks": [ + { + "task_id": "タスクID", + "task_name": "タスク名", + "start_date": "開始日 (YYYY-MM-DD)", + "end_date": "終了日 (YYYY-MM-DD)", + "assignee": "担当者名", + "dependencies": ["前提タスクID"], + "progress": 0, + "priority": "優先度(★の数)", + "parent_task_id": "親タスクID", + "tags": ["タグ1", "タグ2"], + "estimated_hours": "工数見積もり(時間)", + "is_milestone": false + } + ] + } + ``` + + - **保存先**: `/output/プロジェクトID_プロジェクト名.json`(上書き) + + - **対話履歴の追記** + - **ファイル名**: `プロジェクトID_プロジェクト名.md` + - **保存先**: `/docs` ディレクトリ + - **内容**: この更新対話の履歴を既存ファイルに追記(タイムスタンプ付き) + +18. **Google Drive手動アップロードの案内** + - 完了後、以下のメッセージを表示: + ``` + ✅ プロジェクトを更新しました + + ローカル保存先: /output/プロジェクトID_プロジェクト名.json + 対話履歴: /docs/プロジェクトID_プロジェクト名.md + + 📂 次のステップ: + Google Driveの「Ganttチャート自動生成」フォルダに + 更新したJSONファイルを手動でアップロードしてください。 + + アップロード後、Google Apps Scriptが自動的に + スプレッドシートを更新します。 + + または、スプレッドシートのメニューから + 「📊 ガントチャート > 📂 Driveから再読み込み」を実行してください。 + ``` + +## 自動判定のガイドライン + +### 依存関係の判定 +- 既存タスクとの関連性を分析 +- 新規タスクの場合、同じPhase内の前後関係を推測 +- 必ずユーザーに確認を取る + +### 工数見積もりの推定 +- タスクの複雑さ、範囲から推定 +- 既存の類似タスクの工数を参考 +- 必ずユーザーに確認を取る + +### 優先度の判定 +- 重要性(プロジェクトへの影響度) +- 緊急性(期日までの余裕) +- 依存関係(他タスクへの影響) +- 既存タスクの優先度傾向を参考 +- 必ずユーザーに確認を取る + +### タグの自動設定 +- タスク名や説明から業務内容を判断 +- 同じPhase内の既存タスクのタグを参考 +- 複数タグ設定可能 +- 必ずユーザーに確認を取る + +## 出力形式の注意点 + +- タスクIDの採番は既存の最大値+1から +- Phase IDの採番は既存の最大値+1から +- 日付はすべてYYYY-MM-DD形式 +- 親タスクのparent_task_idはnull +- マイルストーンは作業時間なし(開始日=終了日) + +## 対話のトーン + +- プロフェッショナルで簡潔 +- 一問一答を厳守 +- 必要に応じて提案や助言を行う +- ユーザーの意図を確認しながら進める +- 既存の構造を尊重しつつ、改善提案も行う + +--- + +それでは、プロジェクト更新を開始します。まず最初の質問をしてください。 diff --git a/.claude/commands/gantt.md b/.claude/commands/gantt.md new file mode 100644 index 0000000..b537520 --- /dev/null +++ b/.claude/commands/gantt.md @@ -0,0 +1,186 @@ +# Ganttチャート自動生成 - 対話型プロジェクト作成 + +あなたは対話型プロジェクト管理アシスタントです。ユーザーとの対話を通じてプロジェクト情報を収集し、Ganttチャート用のJSONファイルと対話履歴を生成します。 + +## 重要なルール + +1. **一問一答形式を厳守**:必ず1つの質問のみを行い、ユーザーの回答を待つこと +2. **段階的確認**:各フェーズ完了時に確認を取る +3. **深掘り対話**:プロジェクト概要とタスク分割では何度も壁打ちして詳細を引き出す + +## 対話の進行順序 + +以下の順序で情報を収集してください: + +### Phase 1: 基本情報収集(Notion連携) + +1. **NotionページURL入力** + - 質問例:「NotionページのURLを入力してください(Beelzebub_ideaデータベースのページURL)」 + - 例: `https://www.notion.so/29dacd79ea7d8106adb5ff4ea997f28b` + - URLを入力してもらう(スキップして手動入力も可能) + +2. **Notionページ取得** + - `mcp__notionMCP__notion-fetch` ツールを使用してページ情報を取得 + - 取得成功した場合: + ``` + Notionから以下の情報を取得しました: + + - プロジェクトID: {Project ID} + - プロジェクト名: {Name} + - 内容: {Content} + - 理由: {Reason} + - 用途: {Usage} + - ステータス: {Status} + - リポジトリURL: {Repo URL}(あれば) + + この情報でプロジェクト作成を開始しますか?(はい/いいえ/修正したい) + ``` + - 取得失敗またはスキップ:手動入力フローに進む + +3. **Notion情報確認** + - 「はい」:Notionの情報を使用してPhase 2へ + - 「修正したい」:該当項目のみ手動入力 + - 「いいえ」:すべて手動入力(Phase 1-4へ) + +4. **手動入力フロー(Notionに情報がない場合)** + - **プロジェクトID入力**:数値4桁(例: 0033) + - **プロジェクト名入力** + - **目的選択**:個人事業 / HRteam + - **プロジェクトジャンル選択**: + 1. 開発プロジェクト + 2. マーケティング + 3. イベント企画 + 4. 業務改善 + 5. 汎用プロジェクト + +### Phase 2: プロジェクト詳細化 + +5. **プロジェクト概要説明** + - 何度も質問を重ねて深掘りする + - プロジェクトの背景、目的、ゴール、制約条件などを引き出す + - 十分に情報が集まったら次へ進む確認を取る + +6. **プロジェクト期日入力** + - 質問例:「プロジェクト全体の期日を入力してください(YYYY-MM-DD形式)」 + +### Phase 3: タスク設計 + +7. **タスク分割** + - 何度も質問を重ねて詳細なタスクに分割 + - 親タスクと小タスクの階層構造を意識 + - マイルストーンの設定も提案 + - 十分にタスクが洗い出せたら確認:「タスク分割完了しました。期日設定に進みますか?」 + +8. **各タスクの期日設定** + - タスクごとに開始日と終了日を設定 + - プロジェクト期日との整合性を確認 + +### Phase 4: 詳細設定 + +9. **詳細設定** + - **担当者**:自由入力(タスクごとに質問) + - **依存関係**:AIが対話内容から自動判定(ユーザーに確認) + - **タグ**:業務分類タグを自動設定(ユーザーに確認) + - 企画・計画、設計、開発・実装、テスト・検証、リリース・デプロイ、運用・保守、マーケティング、営業・商談、事務・管理、その他 + - **工数見積もり**:AIが自動推定(時間単位、ユーザーに確認) + - **優先度**:AIが自動判定(ユーザーに確認) + - ★★★★★、★★★★☆、★★★☆☆、★★☆☆☆、★☆☆☆☆、☆☆☆☆☆ + + すべて設定完了したら確認:「すべて完了しました。JSON生成しますか?」 + +### Phase 5: ファイル生成 + +10. **JSON生成とファイル出力** + - 以下のJSONスキーマで生成: + + ```json + { + "project_id": "プロジェクトID", + "project_name": "プロジェクト名", + "project_purpose": "目的(個人事業/HRteam)", + "project_type": "プロジェクトジャンル", + "project_deadline": "プロジェクト期日 (YYYY-MM-DD)", + "tasks": [ + { + "task_id": "タスクID(T001から自動連番)", + "task_name": "タスク名", + "start_date": "開始日 (YYYY-MM-DD)", + "end_date": "終了日 (YYYY-MM-DD)", + "assignee": "担当者名", + "dependencies": ["前提タスクID"], + "progress": 0, + "priority": "優先度(★の数)", + "parent_task_id": "親タスクID (nullの場合は親タスク)", + "tags": ["タグ1", "タグ2"], + "estimated_hours": "工数見積もり(時間)", + "is_milestone": false + } + ] + } + ``` + + - **JSONファイル名**: `プロジェクトID_プロジェクト名.json` + - **保存先**: `/output` ディレクトリ + + - **対話履歴ファイル名**: `プロジェクトID_プロジェクト名.md` + - **保存先**: `/docs` ディレクトリ + - **内容**: この対話の全履歴を時系列でMarkdown形式で記録 + + 両ファイルを生成後、JSONをGoogle Driveに自動アップロード(環境変数 `GOOGLE_DRIVE_FOLDER_ID` で指定されたフォルダ) + + **Google Driveアップロード後の出力**: + - アップロード成功時、以下の情報を表示: + ``` + ✅ Google Driveにアップロード完了 + + ファイル名: プロジェクトID_プロジェクト名.json + ファイルID: {Google Drive File ID} + URL: https://drive.google.com/file/d/{File ID}/view + フォルダID: {GOOGLE_DRIVE_FOLDER_ID} + + ローカル保存先: /output/プロジェクトID_プロジェクト名.json + 対話履歴: /docs/プロジェクトID_プロジェクト名.md + ``` + +## 自動判定のガイドライン + +### 依存関係の判定 +- タスクの説明から「〜の後に」「〜が完了してから」などの表現を検出 +- 技術的・論理的な依存関係を推測(例:設計→開発→テスト) +- 必ずユーザーに確認を取る + +### 工数見積もりの推定 +- タスクの複雑さ、範囲から推定 +- 類似プロジェクトの経験則を参考 +- 必ずユーザーに確認を取る + +### 優先度の判定 +- 重要性(プロジェクトへの影響度) +- 緊急性(期日までの余裕) +- 依存関係(他タスクへの影響) +- を総合的に判断して6段階で設定 +- 必ずユーザーに確認を取る + +### タグの自動設定 +- タスク名や説明から業務内容を判断 +- 複数タグ設定可能 +- 必ずユーザーに確認を取る + +## 出力形式の注意点 + +- タスクIDは必ずT001から連番で採番 +- 日付はすべてYYYY-MM-DD形式 +- 進捗率の初期値は0 +- 親タスクのparent_task_idはnull +- マイルストーンは作業時間なし(開始日=終了日) + +## 対話のトーン + +- プロフェッショナルで簡潔 +- 一問一答を厳守 +- 必要に応じて提案や助言を行う +- ユーザーの意図を確認しながら進める + +--- + +それでは、プロジェクト作成を開始します。まず最初の質問をしてください。 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..22e2bdd --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Google Drive API設定 +GOOGLE_DRIVE_FOLDER_ID=your_folder_id_here +GOOGLE_CLIENT_ID=your_client_id_here +GOOGLE_CLIENT_SECRET=your_client_secret_here +GOOGLE_REDIRECT_URI=http://localhost:3000/oauth2callback + +# プロジェクト設定 +PROJECT_ROOT=/Users/takashishibata/Desktop/個人事業/185-automatic-gantt-chart-creation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..770aa55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,119 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage/ + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage +.grunt + +# Bower dependency directory +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache +.cache +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Database files +*.db +*.sqlite + +# Gantt Chart Project - Sensitive files +dist/ +.google-token.json +.upload-history.json +client_secret.json +.clasp.json +conversation_history/ +output/ + +# Gantt Chart Project - Claude settings +.claude/settings.local.json +.claude/.DS_Store +.claude/agents/ + +# Gantt Chart Project - JSON files +*.json +!package.json +!tsconfig.json +!gas/appsscript.json +!.mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..8aa421a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,92 @@ +{ + "mcpServers": { + "adobe-xd": { + "command": "node", + "args": [ + "/Users/takashishibata/Desktop/BEELZEBUB/adobe-xd-mcp/dist/index.js" + ] + }, + "serena": { + "type": "stdio", + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide-assistant", + "--project", + "/Users/takashishibata/Desktop/BEELZEBUB" + ], + "env": {} + }, + "playwright": { + "command": "npx", + "args": [ + "@playwright/mcp@latest" + ] + }, + "chrome-devtools": { + "command": "npx", + "args": ["chrome-devtools-mcp@latest"] + }, + "context7": { + "type": "stdio", + "command": "npx", + "args": [ + "--yes", + "@upstash/context7-mcp" + ], + "env": { + "CONTEXT7_API_KEY": "ctx7sk-d154076d-c508-42e7-b5cd-6c9c481b3954" + } + }, + "codex": { + "type": "stdio", + "command": "codex", + "args": ["mcp"] + }, + "mcp-gemini-cli": { + "command": "npx", + "args": ["mcp-gemini-cli", "--allow-npx"] + }, + "file-system": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/Users/takashishibata/Desktop/BEELZEBUB" + ] + }, + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp", + "headers": { + "Authorization": "Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + }, + "supabase": { + "command": "npx", + "args": [ + "-y", + "@supabase/mcp-server-supabase@latest" + ], + "env": { + "SUPABASE_ACCESS_TOKEN": "${SUPABASE_ACCESS_TOKEN}", + "SUPABASE_PROJECT_REF": "${SUPABASE_PROJECT_REF}" + } + }, + "todoist-mcp": { + "command": "node", + "args": ["/Users/takashishibata/.claude/todoist-mcp/build/index.js"], + "env": { + "TODOIST_API_KEY": "dcbc9cfc8aa52fce62a50a09e3ccecd0200faf15" + } + }, + "notionMCP": { + "command": "npx", + "args": ["-y", "mcp-remote", "https://mcp.notion.com/mcp"] + } + } +} \ No newline at end of file diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..70eb6d6 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,324 @@ +# 自動Ganttチャート生成システム 設計書 + +## 1. システム概要 + +Claude Codeを使った対話型プロジェクト管理システム。ユーザーとの対話を通じてプロジェクト情報を収集し、Google Apps Script (GAS)を使ってスプレッドシートに自動でGanttチャートを生成する。 + +## 2. システム構成 + +### 2.1 コンポーネント + +1. **Claude Code対話システム** + - スラッシュコマンド: `/gantt` + - メタプロンプトファイル + - JSON生成機能 + +2. **データ連携層** + - Google Drive API(OAuth認証) + - 自動アップロード機能 + +3. **GASバックエンド** + - ドライブ監視トリガー + - JSON読み込み・解析 + - Ganttチャート生成エンジン + +4. **スプレッドシート出力** + - プロジェクト一覧シート + - 各プロジェクトのGanttチャートシート + +### 2.2 データフロー + +``` +1. ユーザー入力 + └─> Claude Code対話 + └─> JSON生成 + └─> Google Drive APIで指定フォルダにアップロード + └─> GASがドライブ監視(自動トリガー) + └─> JSON読み込み・解析 + └─> 指定スプレッドシートに新規シート追加 + └─> Ganttチャート生成 +``` + +## 3. データ仕様 + +### 3.1 JSONスキーマ(予定) + +```json +{ + "project_id": "プロジェクトID", + "project_name": "プロジェクト名", + "project_purpose": "目的(個人事業/HRteam)", + "project_type": "プロジェクトジャンル", + "project_deadline": "プロジェクト期日 (YYYY-MM-DD)", + "tasks": [ + { + "task_id": "タスクID(T001から自動連番)", + "task_name": "タスク名", + "start_date": "開始日 (YYYY-MM-DD)", + "end_date": "終了日 (YYYY-MM-DD)", + "assignee": "担当者名", + "dependencies": ["前提タスクID"], + "progress": "進捗率 (0-100)", + "priority": "優先度", + "parent_task_id": "親タスクID (nullの場合は親タスク)", + "tags": ["タグ1", "タグ2"], + "estimated_hours": "工数見積もり(時間)", + "is_milestone": "マイルストーンフラグ (true/false)" + } + ] +} +``` + +**注**: 最終的なスキーマはGASとの相性を考慮して調整 + +### 3.2 プロジェクトジャンル + +1. 開発プロジェクト +2. マーケティング +3. イベント企画 +4. 業務改善 +5. 汎用プロジェクト + +**注**: テンプレートは作成せず、ジャンル分け用の情報として使用 + +### 3.3 業務分類タグ(10種類) + +| タグ名 | 説明 | 色 | カラーコード | +|--------|------|-----|-------------| +| **企画** | 構想・アイデア出し・要件定義 | 青 | #4285F4 | +| **設計** | 仕様書作成・アーキテクチャ設計 | 紫 | #9C27B0 | +| **開発** | コーディング・実装作業 | 緑 | #34A853 | +| **デザイン** | UI/UXデザイン・ビジュアル制作 | ピンク | #E91E63 | +| **テスト** | 動作確認・品質検証 | オレンジ | #FF9800 | +| **レビュー** | コードレビュー・成果物確認 | 黄色 | #FBBC04 | +| **資料作成** | ドキュメント・マニュアル作成 | 水色 | #00BCD4 | +| **調整** | 会議・調整・承認取得 | グレー | #9E9E9E | +| **運用** | デプロイ・保守・監視 | 茶色 | #795548 | +| **その他** | 上記に該当しない作業 | ライトグレー | #E0E0E0 | + +**注**: タスクには複数タグを設定可能。Ganttチャート上でタグごとに色分けして視覚化(複数タグの場合は最初のタグの色を使用) + +### 3.4 優先度表現(6段階) + +- ★★★★★(最高優先度) +- ★★★★☆ +- ★★★☆☆ +- ★★☆☆☆ +- ★☆☆☆☆ +- ☆☆☆☆☆(最低優先度) + +AIが自動判定する際は、タスクの重要性・緊急性・依存関係を考慮して設定 + +### 3.5 プロジェクトステータス(5段階) + +- **未着手** - プロジェクト開始前 +- **進行中** - 実行中 +- **完了** - プロジェクト終了 +- **保留** - 一時停止中 +- **中止** - プロジェクト中止 + +初期値は「未着手」、スプレッドシート上で手動更新 + +## 4. 対話フロー設計 + +### 4.1 対話方式 + +- **基本方式**: 順次型(案A)とテンプレート型(案C)のハイブリッド +- **コミュニケーション**: 一問一答形式(必須) +- **段階的確認**: 各フェーズ完了時に確認を行う + +### 4.2 対話の進行順序 + +``` +1. プロジェクトID入力 + ↓ +2. プロジェクト名入力 + ↓ +3. 目的選択(個人事業/HRteam) + ↓ +4. プロジェクトジャンル選択(5種類から) + ↓ +5. プロジェクト概要説明 + ※何度も壁打ちして深掘り + ↓ +6. プロジェクト期日入力 + ↓ +7. タスク分割 + ※何度も壁打ちして深掘り + 【確認】「タスク分割完了しました。期日設定に進みますか?」 + ↓ +8. 各タスクの期日設定 + ↓ +9. 詳細設定(担当者・依存関係・タグ・工数など) + 【確認】「すべて完了しました。JSON生成しますか?」 + ↓ +10. JSON生成 → Google Driveに自動アップロード + - **JSONファイル名**: `プロジェクトID_プロジェクト名.json` → Google Driveにアップロード + - **対話履歴ファイル名**: `プロジェクトID_プロジェクト名.md` → ローカルの`/docs`ディレクトリに保存 +``` + +### 4.3 自動判定項目 + +- **依存関係**: AIが対話内容から自動判定 +- **工数見積もり**: AIが自動推定 +- **優先度**: AIが自動判定 + +### 4.4 手動入力項目 + +- **担当者**: 自由入力 +- **小タスクの完了状態**: スプレッドシート上のステータス列で手動更新 + +## 5. スプレッドシート仕様 + +### 5.1 シート構成 + +1. **プロジェクト一覧シート** + - プロジェクトID + - プロジェクト名 + - プロジェクトジャンル + - 目的(個人事業/HRteam ※将来的に増える可能性あり) + - 作成日 + - 期日(プロジェクト全体の終了予定日) + - ステータス + +2. **全プロジェクトタスクシート** + - 全プロジェクトのタスクを統合表示 + - **表示項目**: プロジェクト名 + 各プロジェクトシート左側の全項目 + - プロジェクト名、タスクID、タスク名、開始日、終了日、進捗率、ステータス、担当者、優先度、タグ、工数 + - **デフォルト並び順**: プロジェクトごとにまとめて表示 + - **更新方法**(3種類実装): + 1. 新規プロジェクト作成時に全タスクを自動追加 + 2. 各プロジェクトシートの変更を監視して自動同期(onEdit トリガー) + 3. 手動更新ボタン(シート上部に配置) + - **機能**: スプレッドシートのフィルタ/並び替え機能で期日順・優先度順など自由に並び替え可能 + +3. **各プロジェクトのGanttチャートシート** + - シート名: `プロジェクトID_プロジェクト名` + - 上部: 基本情報(プロジェクトID、プロジェクト名、ジャンル等) + - メイン: Ganttチャート + +### 5.2 Ganttチャートスタイル(標準構成) + +**レイアウト:** +- 左側: タスクリスト(全項目表示) + - **列の並び順**: タスクID → タスク名 → 開始日 → 終了日 → 進捗率 → ステータス → [折りたたみ]担当者 → 優先度 → タグ → 工数 + - **行の並び順**: タスクIDの昇順(T001 → T002 → T003...) + - **親子関係の表示**: 小タスクをインデント表示(親タスクの下に字下げ) + - **常に表示**: タスクID、タスク名、開始日、終了日、進捗率、ステータス + - **デフォルト折りたたみ**: 担当者、優先度、タグ、工数見積もり + - GASで列グループ化機能を使用して折りたたみ設定 +- 右側: タイムライン(日付軸に沿った棒グラフ) + - **日付軸の単位**: 日単位 + +**視覚要素:** +1. タグによる色分け(業務種別の視覚化) +2. 週末のグレー背景(スケジュール感覚) +3. 進捗率は棒の塗りつぶし(例: 50%完了なら棒の半分を濃色) +4. 依存関係は灰色の実線矢印(#666666)で前提タスクからの関係を可視化 +5. マイルストーンは赤色の◆マーク(#EA4335)+ テキストラベル付き + +### 5.3 進捗管理 + +- **親タスク**: 配下の小タスクの完了状況から自動計算 +- **小タスク**: ステータス列(ドロップダウン: 未着手/進行中/完了)を手動更新 +- 親タスクの進捗率 = 完了した小タスク数 / 全小タスク数 × 100 +- **計算ルール**: 「完了」のみカウント(「進行中」は0%扱い) + +## 6. 技術実装 + +### 6.0 環境変数管理 + +- **Claude Code側**: `.env`ファイル(推奨) + - `GOOGLE_DRIVE_FOLDER_ID`: アップロード先フォルダID +- **GAS側**: スクリプトプロパティ + - `SPREADSHEET_ID`: 出力先スプレッドシートID + - `DISCORD_WEBHOOK_URL`: エラー通知用Discord Webhook URL + +### 6.1 Google Drive連携 + +- **方式**: Google Drive API使用 +- **認証**: OAuth認証(初回のみ設定) +- **アップロード先**: 指定フォルダ + - **指定方法**: 環境変数(`.env`)でフォルダIDを設定 + +### 6.2 GASトリガー + +- **トリガータイプ**: ドライブ監視(ファイルアップロード検知) +- **実行内容**: + 1. 新規JSONファイル検知 + 2. JSON読み込み・解析 + 3. 指定スプレッドシートに新規Ganttチャートシート追加 + 4. Ganttチャート生成 + 5. プロジェクト一覧シートに自動追加(プロジェクトID、プロジェクト名、目的、ジャンル、作成日、期日、ステータス) + 6. 全プロジェクトタスクシートに全タスクを自動追加 + +### 6.3 スプレッドシート操作 + +- **対象**: 指定された1つのスプレッドシート + - **指定方法**: 環境変数でスプレッドシートURLまたはIDを設定 +- **操作**: 新規シート追加(既存シートは保持) +- **命名規則**: `プロジェクトID_プロジェクト名` + +## 7. 開発タスク + +### 7.1 Phase 1: Claude Code側の実装 +- [ ] メタプロンプトファイル作成 +- [ ] スラッシュコマンド `/gantt` 実装 +- [ ] JSON生成ロジック実装 +- [ ] Google Drive API連携実装 + +### 7.2 Phase 2: GAS側の実装 +- [ ] ドライブ監視トリガー設定 +- [ ] JSON読み込み・解析ロジック +- [ ] スプレッドシート操作ロジック +- [ ] Ganttチャート生成エンジン実装 +- [ ] 進捗率自動計算ロジック +- [ ] 全プロジェクトタスクシート機能 + - 新規プロジェクト作成時の自動追加 + - onEditトリガーによる自動同期 + - 手動更新ボタン実装 +- [ ] エラーハンドリング・Discord通知機能 + - Discord Webhook URLは環境変数で設定 + +### 7.3 Phase 3: 統合とテスト +- [ ] エンドツーエンドテスト +- [ ] エラーハンドリング実装 +- [ ] ドキュメント整備 + +## 10. 次のステップ + +設計仕様が確定しました。次は以下の実装に進みます: + +1. **メタプロンプトファイル作成** - 対話フローを実装 +2. **スラッシュコマンド実装** - `/gantt`コマンドの作成 +3. **JSON生成ロジック** - 対話情報からJSONを生成 +4. **Google Drive API連携** - 自動アップロード機能 +5. **GASスクリプト実装** - Ganttチャート生成エンジン + +**ステータス**: 設計完了 ✓ (最終更新: 2025-12-17) + +**主要機能まとめ:** +- 対話型プロジェクト作成(10ステップ) +- JSON自動生成 + Google Drive自動アップロード +- GAS自動トリガー + Ganttチャート生成 +- プロジェクト一覧シート +- 全プロジェクトタスクシート(3種類の更新方法) +- 各プロジェクトGanttチャートシート +- Discord通知 + エラーハンドリング + +## 8. 保留事項 + +### 8.1 全体管理シートの仕様 +- プロジェクト一覧シートの詳細機能 +- 複数プロジェクト間の依存関係管理 +- リソース配分の可視化 +- ダッシュボード機能 + +**→ 後で仕様を検討・実装** + +## 9. 補足 + +- 対話は常に一問一答形式で進行 +- 各フェーズで段階的確認を実施 +- AIが自動判定する項目と手動入力項目を明確に分離 +- スプレッドシート上での手動更新を前提とした進捗管理 diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..7cd8ac5 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,268 @@ +# インストールガイド + +Claude Code対話型Ganttチャート自動生成システムのセットアップ手順を説明します。 + +## 📋 前提条件 + +- **Node.js**: v20.x以上 +- **npm**: v10.x以上 +- **Claude Code CLI**: インストール済み +- **Google Cloud Platform**: アカウント作成済み +- **Google Apps Script**: 基本的な知識 + +## 🚀 セットアップ手順 + +### 1. リポジトリのクローン + +```bash +git clone https://github.com/{your-username}/claude-gantt-chart.git +cd claude-gantt-chart +``` + +### 2. 依存関係のインストール + +```bash +npm install +``` + +### 3. 環境変数の設定 + +`.env.example`をコピーして`.env`を作成: + +```bash +cp .env.example .env +``` + +`.env`ファイルを編集: + +```env +# Google Drive設定 +GOOGLE_DRIVE_FOLDER_ID=your_folder_id_here +GOOGLE_CLIENT_ID=your_client_id_here +GOOGLE_CLIENT_SECRET=your_client_secret_here +GOOGLE_REDIRECT_URI=http://localhost:19204/oauth2callback + +# スプレッドシートID(Discord通知用、オプション) +GOOGLE_SPREADSHEET_ID=your_spreadsheet_id_here + +# Discord通知設定(オプション) +DISCORD_WEBHOOK_URL=your_webhook_url_here + +# Todoist API設定(オプション) +TODOIST_API_KEY=your_todoist_api_token_here +``` + +### 4. Google Cloud Platformの設定 + +#### 4.1 プロジェクト作成 + +1. [Google Cloud Console](https://console.cloud.google.com/)にアクセス +2. 新規プロジェクトを作成 + +#### 4.2 Google Drive API有効化 + +1. 「APIとサービス」→「ライブラリ」 +2. 「Google Drive API」を検索して有効化 + - URL: https://console.cloud.google.com/apis/library/drive.googleapis.com + +#### 4.3 OAuth 2.0認証情報の作成 + +1. 「APIとサービス」→「認証情報」 +2. 「認証情報を作成」→「OAuth 2.0 クライアントID」 +3. アプリケーションの種類: **デスクトップアプリ** +4. リダイレクトURI: `http://localhost:19204/oauth2callback` +5. クライアントIDとシークレットを`.env`に設定 + +#### 4.4 Google Driveフォルダ作成 + +1. Google Driveで新規フォルダを作成(例: `Gantt-JSON-Files`) +2. フォルダURLからフォルダIDを取得: + - URL形式: `https://drive.google.com/drive/folders/{FOLDER_ID}` + - `{FOLDER_ID}`の部分を`.env`の`GOOGLE_DRIVE_FOLDER_ID`に設定 + +### 5. Google OAuth認証 + +初回認証トークンを取得: + +```bash +npm run gantt:auth +``` + +ブラウザが開き、Googleアカウントでログイン・認証します。 +認証完了後、`.google-token.json`が自動生成されます(gitignoreに含まれます)。 + +### 6. TypeScriptのビルド + +```bash +npm run build +``` + +### 7. Google Apps Scriptの設定 + +#### 7.1 スプレッドシート作成 + +1. [Google Sheets](https://sheets.google.com/)で新規スプレッドシートを作成 +2. スプレッドシートのURLからIDを取得: + - URL形式: `https://docs.google.com/spreadsheets/d/{SPREADSHEET_ID}/edit` + - `{SPREADSHEET_ID}`を`.env`の`GOOGLE_SPREADSHEET_ID`に設定(オプション) + +#### 7.2 Apps Scriptプロジェクト作成 + +1. スプレッドシートを開く +2. 「拡張機能」→「Apps Script」 +3. 新規プロジェクトが作成されます + +#### 7.3 GASコードのコピー + +`gas/`フォルダ内の全ファイルをApps Scriptエディタにコピー: + +- `Config.gs` +- `SheetManager.gs` +- `GanttRenderer.gs` +- `Code.gs` + +#### 7.4 Config.gsの設定 + +`Config.gs`を開き、以下を設定: + +```javascript +const CONFIG = { + DRIVE_FOLDER_ID: 'あなたのフォルダID', // .envと同じ値 + DISCORD_WEBHOOK_URL: 'あなたのWebhook URL', // オプション + TODOIST_API_KEY: 'あなたのTodoist APIトークン', // オプション + // ...その他の設定 +}; +``` + +#### 7.5 時間トリガーの設定 + +1. Apps Scriptエディタで「トリガー」アイコン(⏰)をクリック +2. 「トリガーを追加」をクリック +3. 以下のように設定: + - **実行する関数**: `checkForNewJsonFiles` + - **イベントのソース**: `時間主導型` + - **時間ベースのトリガーのタイプ**: `分ベースのタイマー` + - **時間の間隔**: `1分おき` +4. 「保存」をクリック + +### 8. Todoist統合(オプション) + +Todoistと連携する場合: + +#### 8.1 Todoist APIトークン取得 + +1. [Todoist設定](https://todoist.com/app/settings/integrations/developer)にアクセス +2. 「開発者」タブ→「APIトークン」をコピー +3. `.env`の`TODOIST_API_KEY`に設定 +4. `Config.gs`の`TODOIST_API_KEY`にも設定 + +#### 8.2 Todoistトリガー設定 + +Apps Scriptで以下のトリガーを追加: + +1. **Todoistタスク同期**: + - 実行する関数: `syncTodoistTasks` + - イベントのソース: `時間主導型` + - 時間の間隔: `30分おき` + +2. **期日切れタスク通知**: + - 実行する関数: `sendOverdueTasksNotification` + - イベントのソース: `時間主導型` + - 時間ベースのトリガーのタイプ: `日タイマー` + - 時刻: `午前9時~10時` + +### 9. Discord通知設定(オプション) + +Discord通知を有効にする場合: + +1. Discordサーバーで「サーバー設定」→「連携サービス」→「ウェブフック」 +2. 新しいウェブフックを作成 +3. ウェブフックURLをコピー +4. `.env`の`DISCORD_WEBHOOK_URL`に設定 +5. `Config.gs`の`DISCORD_WEBHOOK_URL`にも設定 + +## ✅ 動作確認 + +### 1. Claude Codeで動作確認 + +```bash +# プロジェクトディレクトリで +claude code +``` + +Claude Codeのプロンプトで: + +``` +/gantt +``` + +スキルが正常に認識されれば成功です。 + +### 2. JSONファイル生成テスト + +1. `/gantt`コマンドで対話形式でプロジェクト情報を入力 +2. 完了後、以下を実行: + +```bash +npm run gantt:save +``` + +3. `output/`フォルダにJSONファイルが生成されることを確認 +4. Google Driveにアップロードされることを確認 + +### 3. Ganttチャート生成テスト + +1. JSONファイルがGoogle Driveにアップロードされた後、1分以内に自動処理される +2. スプレッドシートに以下のシートが作成されることを確認: + - プロジェクト一覧 + - 全プロジェクトタスク + - 期日切れタスク + - 個別Ganttチャート + +## 🛠️ トラブルシューティング + +### Q1. OAuth認証がうまくいかない + +**A**: 以下を確認: +1. Google Drive APIが有効化されているか +2. `.env`のクライアントID・シークレットが正確か +3. リダイレクトURIが正確に設定されているか + +### Q2. JSONファイルが生成されない + +**A**: 以下を確認: +1. `npm run build`を実行したか +2. TypeScriptのコンパイルエラーがないか +3. `npm run gantt:save`を実行したか + +### Q3. Ganttチャートが自動生成されない + +**A**: 以下を確認: +1. 時間トリガーが正しく設定されているか +2. `Config.gs`の`DRIVE_FOLDER_ID`が正しいか +3. Apps Scriptの実行ログでエラーがないか +4. JSONファイルが正しいフォルダにアップロードされているか + +### Q4. Todoistタスクが同期されない + +**A**: 以下を確認: +1. `TODOIST_API_KEY`が正しく設定されているか +2. Todoistトリガーが正しく設定されているか +3. `testTodoistIntegration()`関数で手動テストする + +## 📚 次のステップ + +- [README.md](README.md) - 詳細な使い方 +- [gas/README.md](gas/README.md) - GAS側の詳細ドキュメント +- [DESIGN.md](DESIGN.md) - システム設計ドキュメント + +## 🆘 サポート + +問題が発生した場合: +1. [Issues](https://github.com/{your-username}/claude-gantt-chart/issues)で報告 +2. トラブルシューティングセクションを確認 +3. Apps Scriptの実行ログを確認 + +## 📝 ライセンス + +MIT License diff --git a/README.md b/README.md new file mode 100644 index 0000000..965668d --- /dev/null +++ b/README.md @@ -0,0 +1,502 @@ +# Claude Code対話型Ganttチャート自動生成システム + +Claude Codeとの対話でプロジェクトを設計し、JSONファイルを自動生成して、Google Apps Scriptでスプレッドシートに美しいGanttチャートを自動作成するシステムです。 + +## 📋 目次 + +- [機能概要](#機能概要) +- [システム構成](#システム構成) +- [セットアップ](#セットアップ) +- [使い方](#使い方) +- [データ仕様](#データ仕様) +- [GAS統合](#gas統合) +- [トラブルシューティング](#トラブルシューティング) + +--- + +## 🎯 機能概要 + +### Phase 1: Claude Code側(このリポジトリ) + +- **対話型プロジェクト設計**: `/gantt` スラッシュコマンドで段階的にプロジェクト情報を入力 +- **JSON自動生成**: プロジェクト基本情報とタスク一覧を構造化JSONで出力 +- **対話履歴保存**: プロジェクト作成プロセスをMarkdown形式で記録 +- **Google Driveアップロード**: 生成したJSONファイルを自動アップロード + +### Phase 2: GAS側(✅ 実装完了) + +- **自動Ganttチャート生成**: JSONファイルからスプレッドシートに視覚的なGanttチャートを作成 +- **4シート構成**: プロジェクト一覧、全プロジェクトタスク、期日切れタスク、Todoistタスク、個別Ganttチャート +- **タスク管理機能**: 進捗率、依存関係、担当者、優先度の管理 +- **Todoist統合**: TodoistのInboxタスクを自動同期してスプレッドシートで一元管理 +- **期日切れタスク通知**: 期日を過ぎたタスクを自動検出してDiscord通知 +- **Discord通知**: 成功/エラー通知をDiscordに自動送信 + +--- + +## 🏗️ システム構成 + +``` +185-automatic-gantt-chart-creation/ +├── .claude/ +│ └── commands/ +│ └── gantt.md # /gantt スラッシュコマンド定義 +├── src/ +│ └── gantt-helper.ts # ヘルパースクリプト(状態管理・JSON生成・Discord通知) +├── gas/ # Google Apps Script ファイル ✨ NEW +│ ├── Config.gs # 設定管理(スプレッドシートID、Discord、色設定) +│ ├── SheetManager.gs # シート操作(作成、更新、データ投入) +│ ├── GanttRenderer.gs # Ganttチャート描画(バー、ヘッダー、スタイル) +│ ├── Code.gs # メインスクリプト(実行エントリーポイント) +│ └── README.md # GAS導入手順・使い方 +├── outputs/ # JSONファイル保存先 +├── docs/ # 対話履歴ファイル保存先 +├── package.json # 依存関係 +├── tsconfig.json # TypeScript設定 +├── .env.example # 環境変数テンプレート +├── .gitignore +└── README.md +``` + +--- + +## 🚀 セットアップ + +### 1. 依存関係のインストール + +```bash +npm install +``` + +### 2. 環境変数の設定 + +`.env.example` をコピーして `.env` を作成: + +```bash +cp .env.example .env +``` + +`.env` ファイルを編集して以下の値を設定: + +```env +# Google Drive設定 +GOOGLE_DRIVE_FOLDER_ID=your_folder_id_here +GOOGLE_CLIENT_ID=your_client_id_here +GOOGLE_CLIENT_SECRET=your_client_secret_here +GOOGLE_REDIRECT_URI=http://localhost:19204/oauth2callback + +# スプレッドシートID(Discord通知用、オプション) +GOOGLE_SPREADSHEET_ID=your_spreadsheet_id_here + +# Discord通知設定(オプション) +DISCORD_WEBHOOK_URL=your_webhook_url_here + +# Todoist API設定(オプション) +TODOIST_API_KEY=your_todoist_api_token_here +``` + +### 3. Google OAuth認証の初回セットアップ + +**Google Cloud Consoleで認証情報を取得**: + +1. [Google Cloud Console](https://console.cloud.google.com/) にアクセス +2. 新規プロジェクトを作成 +3. 「APIとサービス」→「ライブラリ」から以下のAPIを有効化: + - **Google Drive API**: https://console.cloud.google.com/apis/library/drive.googleapis.com +4. 「APIとサービス」→「認証情報」 +5. 「OAuth 2.0 クライアントID」を作成 + - アプリケーションの種類: デスクトップアプリ + - リダイレクトURI: `http://localhost:19204/oauth2callback` +6. クライアントIDとクライアントシークレットを`.env`に設定 + +**初回認証トークン取得**: + +```bash +npm run gantt:auth # 初回OAuth認証フロー +``` + +認証が完了すると `.google-token.json` が生成されます(.gitignoreに含まれます)。 + +### 4. GAS側の時間トリガー設定 + +**Google Apps ScriptにDRIVE_FOLDER_IDを設定**: + +1. Apps Scriptエディタを開く +2. `Config.gs`を開く +3. `DRIVE_FOLDER_ID`に`.env`と同じフォルダIDを設定: + ```javascript + DRIVE_FOLDER_ID: 'あなたのフォルダID', + ``` + +**時間ベーストリガーを追加**: + +1. Apps Scriptエディタで左側の「トリガー」アイコン(⏰)をクリック +2. 右下の「トリガーを追加」をクリック +3. 以下のように設定: + - **実行する関数を選択**: `checkForNewJsonFiles` + - **イベントのソースを選択**: `時間主導型` + - **時間ベースのトリガーのタイプを選択**: `分ベースのタイマー` + - **時間の間隔を選択**: `1分おき` +4. 「保存」をクリック + +これで、1分ごとにGoogle Driveフォルダをチェックし、新しいJSONファイルが見つかると自動的にGanttチャートが生成されます。 + +**Todoist統合の追加設定(オプション)**: + +Todoistタスクを同期する場合、以下の追加トリガーを設定してください: + +1. **Todoistタスク同期トリガー**: + - **実行する関数**: `syncTodoistTasks` + - **イベントのソース**: `時間主導型` + - **時間ベースのトリガーのタイプ**: `分ベースのタイマー` + - **時間の間隔**: `30分おき` または `1時間おき` + +2. **期日切れタスク通知トリガー**: + - **実行する関数**: `sendOverdueTasksNotification` + - **イベントのソース**: `時間主導型` + - **時間ベースのトリガーのタイプ**: `日タイマー` + - **時刻**: `午前9時~10時` + +3. **Config.gsにTODOIST_API_KEYを設定**: + ```javascript + TODOIST_API_KEY: 'your_todoist_api_token_here', + ``` + + Todoist APIトークンの取得方法: + - [Todoist設定](https://todoist.com/app/settings/integrations/developer) → 「開発者」タブ + - 「APIトークン」をコピー + +### 5. ビルド(TypeScript → JavaScript変換) + +```bash +npm run build +``` + +--- + +## 📖 使い方 + +### 基本ワークフロー + +#### 1. プロジェクト作成開始 + +Claude Codeで以下のコマンドを実行: + +``` +/gantt +``` + +#### 2. 対話でプロジェクト情報を入力 + +システムが段階的に質問するので、順次回答: + +- **Phase 1: 基本情報収集** + - プロジェクトID: `PRJ001` など + - プロジェクト名: `新規ECサイト開発` + - プロジェクト目的: `個人事業` or `HRteam` + - プロジェクトジャンル: 開発/マーケティング/イベント企画/業務改善/汎用 + - プロジェクト期限: `2025-06-30` + +- **Phase 2-4: タスク設計** + - タスクの追加・修正 + - 依存関係の設定 + - 担当者・優先度・工数の入力 + +#### 3. 完了を宣言 + +すべての情報入力が完了したら: + +``` +完了 +``` + +#### 4. ファイル生成・保存 + +ヘルパースクリプトを実行: + +```bash +npm run gantt:save +``` + +**生成されるファイル**: + +- `outputs/{プロジェクトID}_{プロジェクト名}.json` - プロジェクトデータ(JSON) +- `docs/{プロジェクトID}_{プロジェクト名}.md` - 対話履歴(Markdown) + +ファイルは自動的にGoogle Driveにもアップロードされます。 + +#### 5. GASでGanttチャート作成 + +**✅ 自動実行(時間トリガー方式)**: + +`npm run gantt:save` でJSONファイルをGoogle Driveにアップロード後、**1分以内に自動的にGanttチャートが生成されます**。 + +**動作フロー**: +1. Node.jsがJSONファイルをGoogle Driveにアップロード +2. GAS側の時間トリガー(1分ごと)がフォルダをチェック +3. 未処理のJSONファイルを検出したら自動的に処理 +4. 処理済みファイル名を `processed_XXXX.json` に変更 +5. Discord通知が送信される(設定している場合) + +**手動実行の場合**: +1. スプレッドシートを開く +2. メニュー「Ganttチャート」→「実際のJSONから生成」 +3. または、Apps Scriptエディタで`generateFromDriveFile()`関数を実行 + +--- + +## 📊 データ仕様 + +### プロジェクトJSON形式 + +```json +{ + "project_id": "PRJ001", + "project_name": "新規ECサイト開発", + "project_purpose": "個人事業", + "project_type": "開発", + "project_deadline": "2025-06-30", + "tasks": [ + { + "task_id": "T001", + "task_name": "要件定義", + "start_date": "2025-01-01", + "end_date": "2025-01-15", + "assignee": "山田太郎", + "dependencies": [], + "progress": 0, + "priority": "★★★★★", + "parent_task_id": null, + "tags": ["企画・計画"], + "estimated_hours": 40, + "is_milestone": false + } + ] +} +``` + +### タスクフィールド詳細 + +| フィールド | 型 | 説明 | 例 | +|-----------|-----|------|-----| +| `task_id` | string | タスクID(自動採番) | `T001`, `T002` | +| `task_name` | string | タスク名 | `要件定義` | +| `start_date` | string | 開始日(YYYY-MM-DD) | `2025-01-01` | +| `end_date` | string | 終了日(YYYY-MM-DD) | `2025-01-15` | +| `assignee` | string | 担当者(フリーテキスト) | `山田太郎` | +| `dependencies` | array | 依存タスクID配列 | `["T001", "T002"]` | +| `progress` | number | 進捗率(0-100) | `50` | +| `priority` | string | 優先度(6段階★) | `★★★★★` | +| `parent_task_id` | string\|null | 親タスクID | `T001` or `null` | +| `tags` | array | タグ配列 | `["企画・計画", "技術"]` | +| `estimated_hours` | number | 見積工数(時間) | `40` | +| `is_milestone` | boolean | マイルストーンフラグ | `true` or `false` | + +### 優先度(6段階) + +- `★★★★★` - 最重要 +- `★★★★☆` - 重要 +- `★★★☆☆` - 中 +- `★★☆☆☆` - 普通 +- `★☆☆☆☆` - 低 +- `☆☆☆☆☆` - 最低 + +### タグ分類(10種類) + +| タグ名 | 色 | 説明 | +|--------|-----|------| +| 企画・計画 | 青 | プロジェクト企画、要件定義 | +| 設計 | 緑 | システム設計、UI/UX設計 | +| 開発・実装 | 黄 | プログラミング、コーディング | +| テスト・検証 | 橙 | テスト、品質保証 | +| リリース・デプロイ | 赤 | リリース作業、デプロイ | +| 運用・保守 | 紫 | 運用、保守、監視 | +| マーケティング | ピンク | 広告、プロモーション | +| 営業・商談 | 水色 | 営業活動、商談 | +| 事務・管理 | グレー | 事務作業、管理業務 | +| その他 | 白 | 上記以外 | + +--- + +## 🔗 GAS統合 + +> **📚 詳細な導入手順**: [gas/README.md](gas/README.md) を参照してください + +### GAS側の主な機能(✅ 実装完了) + +#### 1. プロジェクト一覧シート + +- 全プロジェクトのサマリー表示 +- プロジェクトID、名前、目的、ジャンル、期限、進捗率 +- ステータス管理(企画中/進行中/完了/保留/中止) + +#### 2. 全プロジェクトタスクシート + +- 全プロジェクトのタスクを一元管理 +- プロジェクトID + タスクIDでフィルタリング可能 +- 新規プロジェクト作成時に自動追加 +- onEditトリガーで自動同期 +- 手動更新ボタンあり + +#### 3. 期日切れタスクシート + +- 全プロジェクトから期日を過ぎたタスクを自動抽出 +- 期日切れ日数を表示(例: 3日遅れ) +- プロジェクトごとにグループ化 +- Discord通知機能連携 +- 自動更新(全シート同期時) + +#### 4. Todoistタスクシート(オプション) + +- TodoistのInboxタスクを自動同期 +- 表示項目: タスクID、タスク名、説明、期日、優先度、ラベル、完了状態、Todoistリンク、最終更新日時 +- 優先度別の色分け表示(最高=赤、高=橙、中=黄) +- 完了タスクは緑色で表示 +- クリック可能なTodoistリンク(HYPERLINK式) +- 自動更新(トリガー設定で30分〜1時間ごと) + +#### 5. 個別プロジェクトGanttチャートシート + +- プロジェクトごとに1シート作成 +- タイムライン可視化(日単位) +- 進捗バー表示 +- 依存関係の矢印表示 +- 列グループ化(担当者/優先度/タグ/工数は折りたたみ) + +### GASトリガー設定 + +- **時間ベーストリガー** ✅: 1分ごとにGoogle Driveフォルダをチェックし、未処理JSONファイルを自動処理 +- **手動実行** ✅: `testGenerateGantt` または `generateFromDriveFile` 関数で即座にGanttチャート生成 +- **onOpenトリガー** ✅: スプレッドシート起動時のカスタムメニュー追加 +- **onEditトリガー** 🔲: シート編集時の自動同期(今後実装予定) + +**時間トリガーの動作**: +- 関数: `checkForNewJsonFiles` +- 間隔: 1分おき +- 処理: 未処理のJSONファイル(`processed_` で始まらないファイル)を検出して自動処理 +- 処理後: ファイル名を `processed_XXXX.json` に変更 + +--- + +## 🛠️ トラブルシューティング + +### Q1. `npm install` でエラーが発生する + +**A**: Node.jsのバージョンを確認してください。推奨: Node.js 20.x 以上 + +```bash +node --version # v20.x.x 以上を確認 +``` + +### Q2. Google認証がうまくいかない + +**A**: 以下を確認: + +1. `.env` ファイルのクライアントID・シークレットが正しいか +2. Google Drive APIが有効化されているか +3. リダイレクトURIが正確に設定されているか (`http://localhost:3000/oauth2callback`) + +### Q3. JSONファイルが生成されない + +**A**: 以下を確認: + +1. `npm run gantt:save` を実行したか +2. TypeScriptがビルドされているか (`npm run build`) +3. ヘルパースクリプトでエラーが出ていないか + +### Q4. Google Driveアップロードがスキップされる + +**A**: 以下を確認: + +1. `.env` に `GOOGLE_DRIVE_FOLDER_ID` が設定されているか +2. `.google-token.json` が存在するか(初回認証が完了しているか) + +### Q5. Ganttチャートが自動生成されない + +**A**: 以下を確認: + +1. **時間トリガーが正しく設定されているか**: + - Apps Scriptエディタで「トリガー」アイコン(⏰)をクリック + - `checkForNewJsonFiles` 関数のトリガーが「1分おき」で設定されているか確認 + +2. **Config.gsのDRIVE_FOLDER_IDが正しく設定されているか**: + - `.env`のGOOGLE_DRIVE_FOLDER_IDと同じ値が設定されているか確認 + +3. **JSONファイルが正しいフォルダにアップロードされているか**: + - Google Driveで該当フォルダを開き、JSONファイルが存在するか確認 + +4. **GASの実行ログを確認**: + - Apps Scriptエディタで「実行ログ」を開く + - `checkForNewJsonFiles` の実行ログを確認 + - エラーメッセージがあれば内容を確認 + +### Q6. 処理済みファイルが増えすぎた場合 + +**A**: 処理済みファイル(`processed_` で始まるファイル)は手動で削除またはアーカイブできます: + +1. Google Driveで該当フォルダを開く +2. `processed_` で始まるファイルを選択 +3. 別のフォルダに移動するか削除する + +### Q7. 特定のJSONファイルだけ処理したい + +**A**: 手動実行を使用してください: + +1. Google DriveでJSONファイルを右クリック → 「リンクを取得」 +2. URLからファイルIDを取得(`/d/` と `/view` の間の文字列) +3. Apps Scriptエディタで `Code.gs` を開く +4. `processJsonFile('ファイルID')` を直接実行 + +### Q8. Todoistタスクが同期されない + +**A**: 以下を確認: + +1. **Config.gsのTODOIST_API_KEYが正しく設定されているか**: + - [Todoist設定](https://todoist.com/app/settings/integrations/developer) でAPIトークンを確認 + - Config.gsに正しく貼り付けられているか確認 + +2. **トリガーが正しく設定されているか**: + - Apps Scriptエディタで「トリガー」アイコン(⏰)をクリック + - `syncTodoistTasks` 関数のトリガーが存在するか確認 + +3. **手動実行でテスト**: + - Apps Scriptエディタで `testTodoistIntegration()` 関数を実行 + - 実行ログでエラーメッセージを確認 + +4. **Inboxプロジェクトが存在するか**: + - Todoistアプリで「Inbox」プロジェクトが存在するか確認 + - プロジェクト名が正確に「Inbox」であることを確認(大文字小文字区別) + +### Q9. 期日切れタスク通知が送信されない + +**A**: 以下を確認: + +1. **Discord Webhook URLが正しく設定されているか**: + - Config.gsのDISCORD_WEBHOOK_URLが正しいか確認 + +2. **期日切れタスクが存在するか**: + - 「期日切れタスク」シートを開いて実際に期日切れタスクが存在するか確認 + +3. **トリガーが正しく設定されているか**: + - `sendOverdueTasksNotification` 関数の日タイマーが設定されているか確認 + +4. **手動実行でテスト**: + - `testOverdueTasksFeature()` 関数を実行してDiscord通知をテスト + +--- + +## 📝 ライセンス + +MIT License + +--- + +## 🙏 貢献 + +プルリクエストを歓迎します!バグ報告や機能要望はIssuesでお願いします。 + +--- + +## 📞 サポート + +質問・問題がある場合は、Issuesでお問い合わせください。 diff --git a/gas/.claspignore b/gas/.claspignore new file mode 100644 index 0000000..a37b456 --- /dev/null +++ b/gas/.claspignore @@ -0,0 +1,19 @@ +# Documentation +README.md + +# Temporary files +*.tmp +*.swp +*~ + +# OS files +.DS_Store +Thumbs.db + +# Test files +*.test.gs +*.spec.gs + +# Local config backups +*.backup.gs +GanttRenderer.gs.gs diff --git a/gas/Code.gs b/gas/Code.gs new file mode 100644 index 0000000..2bb8123 --- /dev/null +++ b/gas/Code.gs @@ -0,0 +1,1084 @@ +/** + * Ganttチャート自動生成システム - メインスクリプト + * + * このスクリプトはJSONファイルからGanttチャートを自動生成します + */ + +/** + * スプレッドシートを開いたときにカスタムメニューを追加 + */ +function onOpen() { + const ui = SpreadsheetApp.getUi(); + ui.createMenu('📊 ガントチャート') + .addItem('🔄 すべて更新', 'menuUpdateAll') + .addItem('📂 Driveから再読み込み', 'menuReloadFromDrive') + .addSeparator() + .addItem('📊 ガントチャート更新', 'menuUpdateGantt') + .addItem('📋 全タスク一覧更新', 'menuUpdateAllTasks') + .addItem('⚠️ 期日切れ一覧更新', 'menuUpdateOverdue') + .addSeparator() + .addItem('🔄 バッチ同期 (次の5シート)', 'menuSyncNextBatch') + .addItem('🎨 期日切れ色付け', 'menuUpdateOverdueColors') + .addItem('📝 Todoist同期', 'menuSyncTodoist') + .addToUi(); +} + +/** + * メニュー: すべて更新 + */ +function menuUpdateAll() { + const ui = SpreadsheetApp.getUi(); + try { + menuUpdateGantt(); + menuUpdateAllTasks(); + menuUpdateOverdue(); + ui.alert('✅ 更新完了', 'すべてのシートを更新しました。', ui.ButtonSet.OK); + } catch (e) { + ui.alert('❌ エラー', `更新中にエラーが発生しました:\n${e.message}`, ui.ButtonSet.OK); + } +} + +/** + * メニュー: Driveから再読み込み + * Google DriveのJSONファイルを手動で読み込み、スプレッドシートに反映 + */ +function menuReloadFromDrive() { + const ui = SpreadsheetApp.getUi(); + + // 確認ダイアログ + const response = ui.alert( + '📂 Driveから再読み込み', + 'Google Drive上のJSONファイルを読み込み、\nスプレッドシートに反映します。\n\n処理を実行しますか?', + ui.ButtonSet.YES_NO + ); + + if (response !== ui.Button.YES) { + return; + } + + try { + // checkForNewJsonFiles を手動実行 + checkForNewJsonFiles(); + + ui.alert( + '✅ 読み込み完了', + 'JSONファイルの読み込みが完了しました。\n\n詳細はログで確認できます。', + ui.ButtonSet.OK + ); + } catch (e) { + ui.alert( + '❌ エラー', + `読み込み中にエラーが発生しました:\n${e.message}`, + ui.ButtonSet.OK + ); + } +} + +/** + * メニュー: ガントチャート更新 + * 現在アクティブなシートがガントチャートシートの場合、そのシートを更新 + */ +function menuUpdateGantt() { + const ss = SpreadsheetApp.getActiveSpreadsheet(); + const activeSheet = ss.getActiveSheet(); + const sheetName = activeSheet.getName(); + + // プロジェクト一覧シートからプロジェクトIDを取得 + const projectListSheet = ss.getSheetByName(CONFIG.SHEET_NAMES.PROJECT_LIST); + if (!projectListSheet) { + throw new Error('プロジェクト一覧シートが見つかりません。'); + } + + // ガントチャートシートかどうか判定(プロジェクト一覧からリンクを確認) + const data = projectListSheet.getDataRange().getValues(); + let projectId = null; + + for (let i = 1; i < data.length; i++) { + const rowProjectId = data[i][0]; + const expectedSheetName = `${rowProjectId}_`; + if (sheetName.startsWith(expectedSheetName)) { + projectId = rowProjectId; + break; + } + } + + if (!projectId) { + // アクティブシートがガントチャートでない場合、選択ダイアログを表示するか最初のプロジェクトを更新 + SpreadsheetApp.getUi().alert('⚠️ 注意', '現在のシートはガントチャートシートではありません。\nガントチャートシートを選択してから実行してください。', SpreadsheetApp.getUi().ButtonSet.OK); + return; + } + + // DriveからJSONを再読み込みしてガントチャートを更新 + const folder = DriveApp.getFolderById(CONFIG.DRIVE_FOLDER_ID); + const files = folder.getFiles(); + + while (files.hasNext()) { + const file = files.next(); + const fileName = file.getName(); + + if (fileName.includes(projectId) && fileName.endsWith('.json')) { + const content = file.getBlob().getDataAsString(); + const projectData = JSON.parse(content); + + const renderer = new GanttRenderer(activeSheet, projectData); + renderer.render(); + Logger.log(`✓ ガントチャート「${sheetName}」を更新しました`); + return; + } + } + + throw new Error(`プロジェクトID ${projectId} のJSONファイルが見つかりません。`); +} + +/** + * メニュー: 全タスク一覧更新 + */ +function menuUpdateAllTasks() { + const sheetManager = new SheetManager(); + const folder = DriveApp.getFolderById(CONFIG.DRIVE_FOLDER_ID); + const files = folder.getFiles(); + + while (files.hasNext()) { + const file = files.next(); + const fileName = file.getName(); + + if (fileName.endsWith('.json')) { + const content = file.getBlob().getDataAsString(); + const projectData = JSON.parse(content); + sheetManager.updateAllTasks(projectData); + } + } + + Logger.log('✓ 全タスク一覧を更新しました'); +} + +/** + * メニュー: 期日切れ一覧更新 + */ +function menuUpdateOverdue() { + const sheetManager = new SheetManager(); + sheetManager.updateOverdueTasks(); + Logger.log('✓ 期日切れ一覧を更新しました'); +} + +/** + * メニュー: バッチ同期 (次の5シート) + */ +function menuSyncNextBatch() { + const ui = SpreadsheetApp.getUi(); + try { + syncNextBatch(); + ui.alert('✅ 同期完了', 'バッチ同期が完了しました。', ui.ButtonSet.OK); + } catch (e) { + ui.alert('❌ エラー', `同期中にエラーが発生しました:\n${e.message}`, ui.ButtonSet.OK); + } +} + +/** + * メニュー: 期日切れ色付け + */ +function menuUpdateOverdueColors() { + const ui = SpreadsheetApp.getUi(); + try { + updateOverdueColors(); + ui.alert('✅ 色付け完了', '期日切れタスクの色付けが完了しました。', ui.ButtonSet.OK); + } catch (e) { + ui.alert('❌ エラー', `色付け中にエラーが発生しました:\n${e.message}`, ui.ButtonSet.OK); + } +} + +/** + * メニュー: Todoist同期 + */ +function menuSyncTodoist() { + const ui = SpreadsheetApp.getUi(); + try { + syncTodoistTasks(); + ui.alert('✅ 同期完了', 'Todoistタスクの同期が完了しました。', ui.ButtonSet.OK); + } catch (e) { + ui.alert('❌ エラー', `同期中にエラーが発生しました:\n${e.message}`, ui.ButtonSet.OK); + } +} + +/** + * プロジェクトデータからGanttチャートを生成 + * + * @param {Object} projectData - プロジェクトデータ(JSON) + */ +function generateGanttFromData(projectData) { + Logger.log(`プロジェクト「${projectData.project_name}」のGanttチャート生成を開始...`); + + const sheetManager = new SheetManager(); + + // 1. プロジェクトGanttシートを作成&レンダリング + Logger.log('Ganttチャートを作成中...'); + const ganttSheet = sheetManager.getOrCreateGanttSheet( + String(projectData.project_id), + projectData.project_name + ); + + const renderer = new GanttRenderer(ganttSheet, projectData); + renderer.render(); + + // 2. 全タスクリストを更新 + Logger.log('全タスクリストを更新中...'); + sheetManager.updateAllTasks(projectData); + + // 3. プロジェクト一覧を更新(最後に更新してシートリンクを取得) + Logger.log('プロジェクト一覧を更新中...'); + sheetManager.updateProjectList(projectData); + + Logger.log('✓ すべてのシートを更新完了'); +} + +/** + * ファイル名からproject_idを抽出 + * @param {string} fileName - ファイル名(例: "new_192_ポートフォリオ.json" or "update_192_ポートフォリオ.json" or "processed_192_ポートフォリオ.json" or "updated_192_ポートフォリオ.json") + * @return {string|null} - プロジェクトID(例: "192") + */ +function extractProjectId(fileName) { + // new_, update_, processed_, updated_ を除去 + const nameWithoutPrefix = fileName.replace(/^(new_|update_|processed_|updated_)/, ''); + + // 最初の "_" より前を取得 + const match = nameWithoutPrefix.match(/^(\d+)_/); + return match ? match[1] : null; +} + +/** + * プロジェクト一覧シートに既存プロジェクトがあるか判定 + * @param {string} projectId - プロジェクトID + * @return {boolean} - 既存プロジェクトならtrue + */ +function existsInProjectList(projectId) { + const sheetManager = new SheetManager(); + const projectListSheet = sheetManager.ss.getSheetByName(CONFIG.SHEET_NAMES.PROJECT_LIST); + + if (!projectListSheet) { + return false; + } + + const data = projectListSheet.getDataRange().getValues(); + for (let i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(projectId)) { + return true; + } + } + + return false; +} + +/** + * Google Driveフォルダを監視して未処理のJSONファイルを処理 + * + * この関数は時間ベーストリガー(1分ごと)で自動実行されます。 + * + * 処理フロー: + * 1. CONFIG.DRIVE_FOLDER_IDで指定されたフォルダ内のJSONファイルを検索 + * 2. 未処理ファイル(processed_/updated_で始まらない)を処理 + * 3. 既存プロジェクトの場合は processed_/updated_ 付きでも更新 + * 4. 処理後、ファイル名を変更: + * - 新規作成: "processed_XXXX.json" + * - 既存更新: "updated_XXXX.json" + * + * トリガー設定方法: + * 1. Apps Scriptエディタで「トリガー」アイコンをクリック + * 2. 「トリガーを追加」をクリック + * 3. 実行する関数: checkForNewJsonFiles + * 4. イベントのソース: 時間主導型 + * 5. 時間ベースのトリガーのタイプ: 分ベースのタイマー + * 6. 時間の間隔: 1分おき + */ +function checkForNewJsonFiles() { + try { + const folderId = CONFIG.DRIVE_FOLDER_ID; + Logger.log(`[DEBUG] フォルダID: ${folderId}`); + + if (!folderId) { + Logger.log('⚠ CONFIG.DRIVE_FOLDER_ID が設定されていません。'); + return; + } + + if (folderId === 'your_folder_id_here') { + Logger.log('⚠ CONFIG.DRIVE_FOLDER_ID がデフォルト値のままです。実際のフォルダIDを設定してください。'); + return; + } + + const folder = DriveApp.getFolderById(folderId); + Logger.log(`[DEBUG] フォルダ名: ${folder.getName()}`); + + const files = folder.getFiles(); // 全ファイルを取得 + + let fileCount = 0; + let processedCount = 0; + + while (files.hasNext()) { + const file = files.next(); + const fileName = file.getName(); + const mimeType = file.getMimeType(); + fileCount++; + + Logger.log(`[DEBUG] ファイル${fileCount}: ${fileName}`); + Logger.log(`[DEBUG] - MIMEタイプ: ${mimeType}`); + Logger.log(`[DEBUG] - .jsonで終わる? ${fileName.endsWith('.json')}`); + Logger.log(`[DEBUG] - processed/updated済み? ${fileName.startsWith('processed_') || fileName.startsWith('updated_')}`); + + // JSONファイルかチェック + if (!fileName.endsWith('.json')) { + Logger.log(`⏭ スキップ(JSONではない): ${fileName}`); + continue; + } + + // project_idを抽出 + const projectId = extractProjectId(fileName); + Logger.log(`[DEBUG] - プロジェクトID: ${projectId}`); + + // 処理条件の判定 + const isNewFile = fileName.startsWith('new_'); + const isUpdateFile = fileName.startsWith('update_'); + const isProcessedFile = fileName.startsWith('processed_') || fileName.startsWith('updated_'); + const isExistingProject = projectId && existsInProjectList(projectId); + + // 処理すべきかチェック + const shouldProcess = (isNewFile || isUpdateFile) && !isProcessedFile; + + if (shouldProcess) { + // new_ で始まる場合は新規作成 + if (isNewFile) { + Logger.log(`✓ 新規プロジェクト作成を検出: ${fileName}`); + } + // update_ で始まる場合は既存プロジェクト更新 + else if (isUpdateFile) { + if (!isExistingProject) { + Logger.log(`⚠ 警告: ${fileName} は update_ プレフィックスですが、プロジェクトID ${projectId} が存在しません。新規作成として処理します。`); + } else { + Logger.log(`✓ 既存プロジェクトの更新を検出: ${fileName}`); + } + } + + try { + // JSONファイルを処理 + processJsonFile(file.getId()); + + // 処理成功後、ファイル名を変更 + const cleanFileName = fileName.replace(/^(new_|update_)/, ''); + let newFileName; + + if (isUpdateFile && isExistingProject) { + // update_ で始まり、かつ既存プロジェクトの場合は updated_ を付ける + newFileName = 'updated_' + cleanFileName; + Logger.log(`✓ 更新完了。ファイル名を変更: ${newFileName}`); + } else { + // new_ で始まる、または update_ でも既存プロジェクトが見つからない場合は processed_ を付ける + newFileName = 'processed_' + cleanFileName; + Logger.log(`✓ 新規作成完了。ファイル名を変更: ${newFileName}`); + } + + file.setName(newFileName); + processedCount++; + } catch (error) { + Logger.log(`✗ ファイル処理エラー (${fileName}): ${error.message}`); + Logger.log(`✗ スタックトレース: ${error.stack}`); + // エラーが発生してもループは続ける + } + } else { + Logger.log(`⏭ スキップ: ${fileName}`); + } + } + + Logger.log(`[DEBUG] 検出ファイル数: ${fileCount}, 処理数: ${processedCount}`); + + if (processedCount > 0) { + Logger.log(`✓ ${processedCount}個のファイルを処理しました。`); + } else { + Logger.log('未処理のJSONファイルはありません。'); + } + + } catch (error) { + Logger.log('✗ フォルダ監視エラー: ' + error.message); + Logger.log('✗ スタックトレース: ' + error.stack); + sendDiscordNotification( + `Google Driveフォルダの監視中にエラーが発生しました。\n\nエラー内容: ${error.message}`, + true + ); + } +} + +/** + * 手動でJSONファイルを指定して処理 + * + * @param {string} fileId - Google DriveのファイルID + */ +function processJsonFile(fileId) { + try { + const file = DriveApp.getFileById(fileId); + const content = file.getBlob().getDataAsString(); + const projectData = JSON.parse(content); + + Logger.log(`JSONファイル「${file.getName()}」を処理中...`); + generateGanttFromData(projectData); + + sendDiscordNotification( + `プロジェクト「${projectData.project_name}」のGanttチャートを生成しました。\n` + + `ファイル: ${file.getName()}\n` + + `スプレッドシート: https://docs.google.com/spreadsheets/d/${CONFIG.SPREADSHEET_ID}` + ); + + } catch (error) { + Logger.log('✗ エラー: ' + error); + sendDiscordNotification( + `JSONファイル処理中にエラーが発生しました。\n\nエラー内容: ${error.message}`, + true + ); + throw error; + } +} + +/** + * 実際のJSONファイルから生成(Google Driveのファイル使用) + * + * 【自動実行】 + * - npm run gantt:save でアップロード時に自動的にprocessJsonFile()が実行されます + * - Apps Script APIを使用してNode.jsから直接呼び出されます + * + * 【手動実行】 + * 1. Google DriveにJSONファイルをアップロード + * 2. ファイルを右クリック → 「リンクを取得」 → ファイルIDをコピー + * 3. 下記の FILE_ID を実際のファイルIDに書き換え + * 4. この関数を実行 + * + * ファイルIDの例: "1a2b3c4d5e6f7g8h9i0j" (長い英数字の文字列) + */ +function generateGanttFromFile() { + const FILE_ID = 'your_file_id_here'; // ここに実際のファイルIDを入力 + + if (FILE_ID === 'your_file_id_here') { + Logger.log('⚠ FILE_ID を実際のファイルIDに書き換えてください。'); + return; + } + + processJsonFile(FILE_ID); +} + +/** + * 全Ganttシートを一括再同期 + * + * - 全Ganttシートからプロジェクトデータを読み取り + * - プロジェクト一覧、全タスクリスト、期日切れタスクを更新 + */ +function syncNextBatch() { + try { + const scriptProperties = PropertiesService.getScriptProperties(); + const BATCH_SIZE = 5; + + // 現在のインデックスを取得(初回は0) + let currentIndex = parseInt(scriptProperties.getProperty('SYNC_CURRENT_INDEX') || '0'); + + Logger.log(`=== 自動継続バッチ同期開始 (開始インデックス: ${currentIndex}) ===`); + + const sheetManager = new SheetManager(); + const ss = sheetManager.ss; + const allSheets = ss.getSheets(); + + // Ganttシートを検出 + const ganttSheets = allSheets.filter(sheet => { + const name = sheet.getName(); + return name.match(/^\d{1,4}_.+$/); + }); + + const totalSheets = ganttSheets.length; + Logger.log(`✓ 総Ganttシート数: ${totalSheets}件`); + + if (totalSheets === 0) { + Logger.log('⚠ Ganttシートが見つかりませんでした。'); + scriptProperties.setProperty('SYNC_CURRENT_INDEX', '0'); + return; + } + + // インデックスが範囲外の場合はリセット + if (currentIndex >= totalSheets) { + Logger.log('✓ 全シート処理完了。最初に戻ります。'); + currentIndex = 0; + } + + // バッチ範囲を計算 + const endIndex = Math.min(currentIndex + BATCH_SIZE, totalSheets); + const batchSheets = ganttSheets.slice(currentIndex, endIndex); + const isLastBatch = endIndex >= totalSheets; + + Logger.log(`✓ バッチ処理: ${batchSheets.length}件 (${currentIndex}~${endIndex - 1}) ${isLastBatch ? '[最終バッチ]' : ''}`); + + // 各シートを同期 + let successCount = 0; + let errorCount = 0; + + for (const ganttSheet of batchSheets) { + try { + Logger.log(`--- ${ganttSheet.getName()} を同期中 ---`); + + const projectData = sheetManager.readProjectDataFromSheet(ganttSheet); + + if (!projectData) { + Logger.log(`⚠ データ読み取り失敗: ${ganttSheet.getName()}`); + errorCount++; + continue; + } + + sheetManager.updateProjectList(projectData); + sheetManager.updateAllTasks(projectData); + sheetManager.updateGanttProgressFromStatus(ganttSheet); + + Logger.log(`✓ 同期完了: ${ganttSheet.getName()}`); + successCount++; + + Utilities.sleep(1000); + + } catch (error) { + Logger.log(`✗ 同期エラー (${ganttSheet.getName()}): ${error.message}`); + errorCount++; + } + } + + // 最終バッチの場合は期日切れタスク更新 + if (isLastBatch) { + Logger.log('--- 期日切れタスクシートを更新中 ---'); + try { + const overdueCount = sheetManager.updateOverdueTasks(); + Logger.log(`✓ 期日切れタスク更新完了: ${overdueCount}件`); + } catch (error) { + Logger.log(`✗ 期日切れタスク更新エラー: ${error.message}`); + } + } + + // 次回のインデックスを保存 + const nextIndex = isLastBatch ? 0 : endIndex; + scriptProperties.setProperty('SYNC_CURRENT_INDEX', nextIndex.toString()); + + Logger.log('=== バッチ同期完了 ==='); + Logger.log(`成功: ${successCount}件 / エラー: ${errorCount}件`); + Logger.log(`次回開始インデックス: ${nextIndex} ${isLastBatch ? '(最初に戻る)' : ''}`); + + } catch (error) { + Logger.log('✗ バッチ全体エラー: ' + error.message); + Logger.log('✗ スタックトレース: ' + error.stack); + throw error; + } +} + +/** + * 全Ganttシートを強制的に再同期 + * (変更検知機能を削除したため、syncAllSheets()と同じ) + */ +function updateOverdueColors() { + try { + Logger.log('=== 期日切れタスクの色付け開始 ==='); + + const sheetManager = new SheetManager(); + const ss = sheetManager.ss; + const allSheets = ss.getSheets(); + + // Ganttシートを検出 + const ganttSheets = allSheets.filter(sheet => { + const name = sheet.getName(); + return name.match(/^\d{1,4}_.+$/); + }); + + Logger.log(`✓ Ganttシート検出: ${ganttSheets.length}件`); + + if (ganttSheets.length === 0) { + Logger.log('⚠ Ganttシートが見つかりませんでした。'); + return; + } + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + let totalColoredCells = 0; + + for (const ganttSheet of ganttSheets) { + try { + // タスクデータの開始行(GanttRendererの構造: 2行目がヘッダー、4行目からタスク) + const lastRow = ganttSheet.getLastRow(); + if (lastRow < 4) continue; + + const taskStartRow = 4; // 固定(4行目からタスクデータ) + + // タスクデータの範囲を取得 + const taskDataRange = ganttSheet.getRange(taskStartRow, 1, lastRow - taskStartRow + 1, 9); + const taskData = taskDataRange.getValues(); + + // 全終了日セル(G列)をリセット(シート上の全タスク行) + const endDateCol = ganttSheet.getRange(taskStartRow, 7, lastRow - taskStartRow + 1, 1); + endDateCol.setBackground('#FFFFFF'); + endDateCol.setFontColor('#000000'); + endDateCol.setFontWeight('normal'); + + let coloredCount = 0; + + // 各タスクをチェック + for (let i = 0; i < taskData.length; i++) { + const row = taskData[i]; + const endDateValue = row[6]; // G列(終了日) + const progress = row[7]; // H列(進捗率) + const status = String(row[4] || ''); // E列(ステータス) + + // 空行はスキップ + if (!row[0]) continue; + + // ステータスが「完了」または「中断」の場合はスキップ + if (status === '完了' || status === '中断') continue; + + // 期日切れ判定(進捗率は0.0-1.0の小数で保存されている) + if (endDateValue && progress !== 1) { + const endDate = new Date(endDateValue); + endDate.setHours(0, 0, 0, 0); + + if (endDate < today) { + // 期日切れ → 色付け + const endDateCell = ganttSheet.getRange(taskStartRow + i, 7); + endDateCell.setBackground('#FF0000'); // 赤背景 + endDateCell.setFontColor('#FFFF00'); // 黄色文字 + endDateCell.setFontWeight('bold'); // 太字 + coloredCount++; + } + } + } + + if (coloredCount > 0) { + Logger.log(`✓ ${ganttSheet.getName()}: ${coloredCount}件の期日切れタスクに色付け`); + } + totalColoredCells += coloredCount; + + } catch (error) { + Logger.log(`✗ 色付けエラー (${ganttSheet.getName()}): ${error.message}`); + } + } + + Logger.log('=== 色付け完了 ==='); + Logger.log(`総計: ${totalColoredCells}件の期日切れタスクに色付け`); + + } catch (error) { + Logger.log('✗ 全体エラー: ' + error.message); + Logger.log('✗ スタックトレース: ' + error.stack); + throw error; + } +} + +/** + * 期日切れタスクをDiscordに通知 + * + * - 期日切れタスクシートのデータを読み取り + * - Discord WebhookでEmbed形式の通知を送信 + */ +function sendOverdueTasksNotification() { + try { + const sheetManager = new SheetManager(); + const overdueSheet = sheetManager.getOrCreateOverdueTasksSheet(); + + // 期日切れタスクを取得 + const lastRow = overdueSheet.getLastRow(); + if (lastRow <= 1) { + Logger.log('✓ 期日切れタスクはありません。'); + return; + } + + // データを読み取り(ヘッダー除く) + const data = overdueSheet.getRange(2, 1, lastRow - 1, 9).getValues(); + + // プロジェクトごとにグループ化 + const projectGroups = {}; + for (const row of data) { + const projectId = String(row[0] || ''); + const projectName = String(row[1] || ''); + const taskId = String(row[2] || ''); + const parentTaskName = String(row[3] || ''); + const childTaskName = String(row[4] || ''); + const dueDate = row[5] || ''; + const assignee = String(row[6] || ''); + const daysOverdue = row[7] || 0; + const progress = row[8] || 0; + + // タスク名を決定 + const taskName = childTaskName || parentTaskName || taskId; + + // 進捗率を正規化(0.0-1.0 → 0-100) + let progressPercent = 0; + if (typeof progress === 'number') { + progressPercent = progress <= 1 ? Math.round(progress * 100) : Math.round(progress); + } + + if (!projectGroups[projectId]) { + projectGroups[projectId] = { + projectName: projectName, + tasks: [] + }; + } + + projectGroups[projectId].tasks.push({ + taskName: taskName, + dueDate: dueDate, + assignee: assignee, + daysOverdue: daysOverdue, + progress: progressPercent + }); + } + + // Embed形式でDiscord通知を作成 + const embeds = []; + + for (const projectId in projectGroups) { + const group = projectGroups[projectId]; + + // タスクリストをMarkdown形式で整形 + const taskList = group.tasks + .slice(0, 10) // 最大10件まで + .map(task => { + const dueDateStr = task.dueDate instanceof Date + ? `${task.dueDate.getMonth() + 1}/${task.dueDate.getDate()}` + : task.dueDate; + return `• **${task.taskName}** (期日: ${dueDateStr}, 遅延: ${task.daysOverdue}日, 進捗: ${task.progress}%)`; + }) + .join('\n'); + + const moreCount = group.tasks.length > 10 ? `\n... 他${group.tasks.length - 10}件` : ''; + + embeds.push({ + title: `⚠️ 期日切れタスク: ${group.projectName}`, + description: taskList + moreCount, + color: 0xFF6B6B, // 赤色 + footer: { + text: `合計 ${group.tasks.length}件の期日切れタスク` + } + }); + } + + if (embeds.length === 0) { + Logger.log('✓ 期日切れタスクはありません。'); + return; + } + + // Discord Webhook送信 + const payload = { embeds: embeds }; + const options = { + method: 'post', + contentType: 'application/json', + payload: JSON.stringify(payload), + muteHttpExceptions: true + }; + + const response = UrlFetchApp.fetch(CONFIG.DISCORD_WEBHOOK_URL, options); + Logger.log(`✓ Discord通知送信完了: ${response.getResponseCode()}`); + + } catch (error) { + Logger.log(`✗ Discord通知エラー: ${error.message}`); + Logger.log(`✗ スタックトレース: ${error.stack}`); + throw error; + } +} + +/** + * TodoistのInboxタスクを同期 + * + * - Todoist REST API v2を使用してInboxタスクを取得 + * - Todoistタスクシートに書き込み + */ +function syncTodoistTasks() { + try { + Logger.log('=== Todoistタスク同期開始 ==='); + + // 1. Todoist APIからタスクを取得 + const apiKey = CONFIG.TODOIST_API_KEY; + if (!apiKey) { + Logger.log('⚠ TODOIST_API_KEY が設定されていません。'); + return 0; + } + + const url = 'https://api.todoist.com/rest/v2/tasks'; + const options = { + method: 'get', + headers: { + 'Authorization': `Bearer ${apiKey}` + }, + muteHttpExceptions: true + }; + + const response = UrlFetchApp.fetch(url, options); + const responseCode = response.getResponseCode(); + + if (responseCode !== 200) { + Logger.log(`✗ Todoist API エラー: ${responseCode}`); + Logger.log(`✗ レスポンス: ${response.getContentText()}`); + return 0; + } + + const allTasks = JSON.parse(response.getContentText()); + Logger.log(`✓ Todoistタスク取得完了: ${allTasks.length}件`); + + // 2. プロジェクト一覧を取得してInboxプロジェクトIDを特定 + const projectsUrl = 'https://api.todoist.com/rest/v2/projects'; + const projectsResponse = UrlFetchApp.fetch(projectsUrl, options); + const projects = JSON.parse(projectsResponse.getContentText()); + + let inboxProjectId = null; + for (const project of projects) { + if (project.name === 'Inbox' || project.is_inbox_project) { + inboxProjectId = String(project.id); + Logger.log(`✓ Inboxプロジェクト検出: ${inboxProjectId}`); + break; + } + } + + if (!inboxProjectId) { + Logger.log('⚠ InboxプロジェクトIDが見つかりませんでした。'); + return 0; + } + + // 3. Inboxプロジェクトのタスクのみをフィルタリング + const inboxTasks = allTasks.filter(task => String(task.project_id) === inboxProjectId); + Logger.log(`✓ Inboxタスク抽出完了: ${inboxTasks.length}件`); + + // 4. シートマネージャーを使用してタスクを更新 + const sheetManager = new SheetManager(); + const count = sheetManager.updateTodoistTasks(inboxTasks); + + Logger.log(`✓ Todoistタスク同期完了: ${count}件`); + return count; + + } catch (error) { + Logger.log(`✗ Todoistタスク同期エラー: ${error.message}`); + Logger.log(`✗ スタックトレース: ${error.stack}`); + throw error; + } +} + +/** + * Todoist連携機能のテスト(開発用) + * + * 使用方法: + * 1. GASエディタでこの関数を選択 + * 2. 実行ボタンをクリック + * 3. 以下がテストされます: + * - Todoist APIからInboxタスクを取得 + * - Todoistタスクシートへの書き込み + * - Discord通知の送信 + */ +/** + * 既存シートに実績工数列を追加するマイグレーション関数 + * + * 【重要】この関数は一度だけ実行してください。 + * + * 処理内容: + * 1. 全Ganttシート(X_プロジェクト名形式)の12列目(L列)に「実績工数(h)」列を挿入 + * 2. 全プロジェクトタスクシートの14列目(N列)に「実績工数(h)」列を挿入 + * + * 使用方法: + * 1. GASエディタでこの関数を選択 + * 2. 実行ボタンをクリック + * 3. 実行後、各シートに「実績工数(h)」列が追加されたことを確認 + */ +function migrateExistingSheetsForActualHours() { + try { + Logger.log('=== 実績工数列マイグレーション開始 ==='); + + const sheetManager = new SheetManager(); + const ss = sheetManager.ss; + const allSheets = ss.getSheets(); + + let ganttCount = 0; + let errorCount = 0; + + // 1. 全Ganttシートを処理 + Logger.log('--- Ganttシートを処理中 ---'); + + for (const sheet of allSheets) { + const sheetName = sheet.getName(); + + // Ganttシート(X_プロジェクト名 または XXXX_プロジェクト名形式)を検出 + if (sheetName.match(/^\d{1,4}_.+$/)) { + try { + Logger.log(`処理中: ${sheetName}`); + + // 2行目(ヘッダー行)の12列目(L列)の値を確認 + const currentHeader = sheet.getRange(2, 12).getValue(); + + // すでに「実績工数(h)」がある場合はスキップ + if (currentHeader === '実績工数(h)') { + Logger.log(` ⏭ スキップ(すでに実績工数列あり): ${sheetName}`); + continue; + } + + // 12列目(L列)に新しい列を挿入 + sheet.insertColumnAfter(11); // K列の後ろに挿入 + + // ヘッダー行(2行目)の12列目に「実績工数(h)」を設定 + sheet.getRange(2, 12).setValue('実績工数(h)'); + + // ヘッダーのスタイルを適用(他のヘッダーと同じスタイル) + const headerCell = sheet.getRange(2, 12); + headerCell.setBackground('#E8F4FD'); + headerCell.setHorizontalAlignment('center'); + headerCell.setVerticalAlignment('middle'); + + // 1行目の12列目も同じ色で塗る + sheet.getRange(1, 12).setBackground('#E8F4FD'); + sheet.getRange(1, 12).setHorizontalAlignment('center'); + sheet.getRange(1, 12).setVerticalAlignment('middle'); + + Logger.log(` ✓ 完了: ${sheetName}`); + ganttCount++; + + } catch (error) { + Logger.log(` ✗ エラー (${sheetName}): ${error.message}`); + errorCount++; + } + } + } + + Logger.log(`✓ Ganttシート処理完了: ${ganttCount}件`); + + // 2. 全プロジェクトタスクシートを処理 + Logger.log('--- 全プロジェクトタスクシートを処理中 ---'); + + const allTasksSheet = ss.getSheetByName(CONFIG.SHEET_NAMES.ALL_TASKS); + + if (allTasksSheet) { + try { + // 1行目(ヘッダー行)の14列目(N列)の値を確認 + const currentHeader = allTasksSheet.getRange(1, 14).getValue(); + + // すでに「実績工数(h)」がある場合はスキップ + if (currentHeader === '実績工数(h)') { + Logger.log(' ⏭ スキップ(すでに実績工数列あり): 全プロジェクトタスク'); + } else { + // 14列目(N列)に新しい列を挿入 + allTasksSheet.insertColumnAfter(13); // M列の後ろに挿入 + + // ヘッダー行(1行目)の14列目に「実績工数(h)」を設定 + allTasksSheet.getRange(1, 14).setValue('実績工数(h)'); + + // ヘッダーのスタイルを適用 + const headerCell = allTasksSheet.getRange(1, 14); + headerCell.setBackground('#4A90E2'); + headerCell.setFontColor('#FFFFFF'); + headerCell.setFontWeight('bold'); + headerCell.setHorizontalAlignment('center'); + + // 列幅を設定 + allTasksSheet.setColumnWidth(14, 75); + + Logger.log(' ✓ 完了: 全プロジェクトタスク'); + } + } catch (error) { + Logger.log(` ✗ エラー (全プロジェクトタスク): ${error.message}`); + errorCount++; + } + } else { + Logger.log(' ⚠ 全プロジェクトタスクシートが見つかりません。'); + } + + Logger.log('=== マイグレーション完了 ==='); + Logger.log(`成功: ${ganttCount}件のGanttシート + 全タスクシート / エラー: ${errorCount}件`); + + // Discord通知 + sendDiscordNotification( + `✅ **実績工数列マイグレーション完了**\n\n` + + `Ganttシート: ${ganttCount}件\n` + + `全プロジェクトタスクシート: 処理済み\n` + + `エラー: ${errorCount}件`, + false + ); + + } catch (error) { + Logger.log('✗ マイグレーションエラー: ' + error.message); + Logger.log('✗ スタックトレース: ' + error.stack); + sendDiscordNotification( + `実績工数列マイグレーション中にエラーが発生しました。\n\nエラー内容: ${error.message}`, + true + ); + throw error; + } +} + +/** + * 依存タスクブロッカー検知: 遅延タスクの影響分析 + * + * この関数は遅延しているタスクを検出し、それらが他のタスクに与える影響を分析します。 + * + * @param {Object} projectData - プロジェクトデータ + * @return {Object} 分析結果 + * { + * delayedTasks: [遅延タスクの配列], + * impactedTasks: { + * delayed_task_id: { + * impactedTaskIds: [影響を受けるタスクIDの配列], + * criticalPathLength: クリティカルパスの長さ + * } + * }, + * circularDependencies: [[循環依存タスクIDの配列], ...], + * taskMap: { task_id: task_object } + * } + */ +function formatDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +// ============================================ +// 変更検知機能(パフォーマンス最適化) +// ============================================ + +/** + * Ganttシートの最終同期日時を取得 + * @param {Sheet} sheet - Ganttシート + * @returns {Date|null} - 最終同期日時(未同期の場合はnull) + */ +function getLastSyncTime(sheet) { + try { + const props = PropertiesService.getDocumentProperties(); + const key = `lastSync_${sheet.getName()}`; + const value = props.getProperty(key); + return value ? new Date(value) : null; + } catch (error) { + return null; + } +} + +/** + * Ganttシートの最終同期日時を記録 + * @param {Sheet} sheet - Ganttシート + */ +function setLastSyncTime(sheet) { + try { + const props = PropertiesService.getDocumentProperties(); + const key = `lastSync_${sheet.getName()}`; + const now = new Date().toISOString(); + props.setProperty(key, now); + } catch (error) { + Logger.log(`⚠ 最終同期日時の記録失敗: ${sheet.getName()} - ${error.message}`); + } +} + +/** + * Ganttシートが同期が必要かどうか判定 + * @param {Sheet} sheet - Ganttシート + * @param {number} minIntervalMinutes - 最小同期間隔(分)デフォルト5分 + * @returns {boolean} - 同期が必要ならtrue + */ +function shouldSyncSheet(sheet, minIntervalMinutes = 5) { + const lastSyncTime = getLastSyncTime(sheet); + + // 初回同期(最終同期日時が未設定) + if (!lastSyncTime) { + return true; + } + + // 現在時刻との差分を計算(分単位) + const now = new Date(); + const diffMinutes = (now - lastSyncTime) / (1000 * 60); + + // 最小間隔を経過していれば同期が必要 + return diffMinutes >= minIntervalMinutes; +} + diff --git a/gas/Code.gs.backup b/gas/Code.gs.backup new file mode 100644 index 0000000..addd907 --- /dev/null +++ b/gas/Code.gs.backup @@ -0,0 +1,3211 @@ +/** + * Ganttチャート自動生成システム - メインスクリプト + * + * このスクリプトはJSONファイルからGanttチャートを自動生成します + */ + +/** + * スプレッドシートを開いたときにカスタムメニューを追加 + */ +function onOpen() { + const ui = SpreadsheetApp.getUi(); + ui.createMenu('📊 ガントチャート') + .addItem('🔄 すべて更新', 'menuUpdateAll') + .addItem('📂 Driveから再読み込み', 'menuReloadFromDrive') + .addSeparator() + .addItem('📊 ガントチャート更新', 'menuUpdateGantt') + .addItem('📋 全タスク一覧更新', 'menuUpdateAllTasks') + .addItem('⚠️ 期日切れ一覧更新', 'menuUpdateOverdue') + .addToUi(); +} + +/** + * メニュー: すべて更新 + */ +function menuUpdateAll() { + const ui = SpreadsheetApp.getUi(); + try { + menuUpdateGantt(); + menuUpdateAllTasks(); + menuUpdateOverdue(); + ui.alert('✅ 更新完了', 'すべてのシートを更新しました。', ui.ButtonSet.OK); + } catch (e) { + ui.alert('❌ エラー', `更新中にエラーが発生しました:\n${e.message}`, ui.ButtonSet.OK); + } +} + +/** + * メニュー: Driveから再読み込み + * Google DriveのJSONファイルを手動で読み込み、スプレッドシートに反映 + */ +function menuReloadFromDrive() { + const ui = SpreadsheetApp.getUi(); + + // 確認ダイアログ + const response = ui.alert( + '📂 Driveから再読み込み', + 'Google Drive上のJSONファイルを読み込み、\nスプレッドシートに反映します。\n\n処理を実行しますか?', + ui.ButtonSet.YES_NO + ); + + if (response !== ui.Button.YES) { + return; + } + + try { + // checkForNewJsonFiles を手動実行 + checkForNewJsonFiles(); + + ui.alert( + '✅ 読み込み完了', + 'JSONファイルの読み込みが完了しました。\n\n詳細はログで確認できます。', + ui.ButtonSet.OK + ); + } catch (e) { + ui.alert( + '❌ エラー', + `読み込み中にエラーが発生しました:\n${e.message}`, + ui.ButtonSet.OK + ); + } +} + +/** + * メニュー: ガントチャート更新 + * 現在アクティブなシートがガントチャートシートの場合、そのシートを更新 + */ +function menuUpdateGantt() { + const ss = SpreadsheetApp.getActiveSpreadsheet(); + const activeSheet = ss.getActiveSheet(); + const sheetName = activeSheet.getName(); + + // プロジェクト一覧シートからプロジェクトIDを取得 + const projectListSheet = ss.getSheetByName(CONFIG.PROJECT_LIST_SHEET_NAME); + if (!projectListSheet) { + throw new Error('プロジェクト一覧シートが見つかりません。'); + } + + // ガントチャートシートかどうか判定(プロジェクト一覧からリンクを確認) + const data = projectListSheet.getDataRange().getValues(); + let projectId = null; + + for (let i = 1; i < data.length; i++) { + const rowProjectId = data[i][0]; + const expectedSheetName = `${rowProjectId}_`; + if (sheetName.startsWith(expectedSheetName)) { + projectId = rowProjectId; + break; + } + } + + if (!projectId) { + // アクティブシートがガントチャートでない場合、選択ダイアログを表示するか最初のプロジェクトを更新 + SpreadsheetApp.getUi().alert('⚠️ 注意', '現在のシートはガントチャートシートではありません。\nガントチャートシートを選択してから実行してください。', SpreadsheetApp.getUi().ButtonSet.OK); + return; + } + + // DriveからJSONを再読み込みしてガントチャートを更新 + const folder = DriveApp.getFolderById(CONFIG.DRIVE_FOLDER_ID); + const files = folder.getFiles(); + + while (files.hasNext()) { + const file = files.next(); + const fileName = file.getName(); + + if (fileName.includes(projectId) && fileName.endsWith('.json')) { + const content = file.getBlob().getDataAsString(); + const projectData = JSON.parse(content); + + const renderer = new GanttRenderer(activeSheet, projectData); + renderer.render(); + Logger.log(`✓ ガントチャート「${sheetName}」を更新しました`); + return; + } + } + + throw new Error(`プロジェクトID ${projectId} のJSONファイルが見つかりません。`); +} + +/** + * メニュー: 全タスク一覧更新 + */ +function menuUpdateAllTasks() { + const sheetManager = new SheetManager(); + const folder = DriveApp.getFolderById(CONFIG.DRIVE_FOLDER_ID); + const files = folder.getFiles(); + + while (files.hasNext()) { + const file = files.next(); + const fileName = file.getName(); + + if (fileName.endsWith('.json')) { + const content = file.getBlob().getDataAsString(); + const projectData = JSON.parse(content); + sheetManager.updateAllTasks(projectData); + } + } + + Logger.log('✓ 全タスク一覧を更新しました'); +} + +/** + * メニュー: 期日切れ一覧更新 + */ +function menuUpdateOverdue() { + const sheetManager = new SheetManager(); + sheetManager.updateOverdueTasks(); + Logger.log('✓ 期日切れ一覧を更新しました'); +} + +/** + * プロジェクトデータからGanttチャートを生成 + * + * @param {Object} projectData - プロジェクトデータ(JSON) + */ +function generateGanttFromData(projectData) { + Logger.log(`プロジェクト「${projectData.project_name}」のGanttチャート生成を開始...`); + + const sheetManager = new SheetManager(); + + // 1. プロジェクトGanttシートを作成&レンダリング + Logger.log('Ganttチャートを作成中...'); + const ganttSheet = sheetManager.getOrCreateGanttSheet( + String(projectData.project_id), + projectData.project_name + ); + + const renderer = new GanttRenderer(ganttSheet, projectData); + renderer.render(); + + // 2. 全タスクリストを更新 + Logger.log('全タスクリストを更新中...'); + sheetManager.updateAllTasks(projectData); + + // 3. プロジェクト一覧を更新(最後に更新してシートリンクを取得) + Logger.log('プロジェクト一覧を更新中...'); + sheetManager.updateProjectList(projectData); + + Logger.log('✓ すべてのシートを更新完了'); +} + +/** + * ファイル名からproject_idを抽出 + * @param {string} fileName - ファイル名(例: "192_ポートフォリオ.json" or "processed_192_ポートフォリオ.json" or "updated_192_ポートフォリオ.json") + * @return {string|null} - プロジェクトID(例: "192") + */ +function extractProjectId(fileName) { + // processed_ または updated_ を除去 + const nameWithoutPrefix = fileName.replace(/^(processed_|updated_)/, ''); + + // 最初の "_" より前を取得 + const match = nameWithoutPrefix.match(/^(\d+)_/); + return match ? match[1] : null; +} + +/** + * プロジェクト一覧シートに既存プロジェクトがあるか判定 + * @param {string} projectId - プロジェクトID + * @return {boolean} - 既存プロジェクトならtrue + */ +function existsInProjectList(projectId) { + const sheetManager = new SheetManager(); + const projectListSheet = sheetManager.ss.getSheetByName(CONFIG.PROJECT_LIST_SHEET_NAME); + + if (!projectListSheet) { + return false; + } + + const data = projectListSheet.getDataRange().getValues(); + for (let i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(projectId)) { + return true; + } + } + + return false; +} + +/** + * Google Driveフォルダを監視して未処理のJSONファイルを処理 + * + * この関数は時間ベーストリガー(1分ごと)で自動実行されます。 + * + * 処理フロー: + * 1. CONFIG.DRIVE_FOLDER_IDで指定されたフォルダ内のJSONファイルを検索 + * 2. 未処理ファイル(processed_/updated_で始まらない)を処理 + * 3. 既存プロジェクトの場合は processed_/updated_ 付きでも更新 + * 4. 処理後、ファイル名を変更: + * - 新規作成: "processed_XXXX.json" + * - 既存更新: "updated_XXXX.json" + * + * トリガー設定方法: + * 1. Apps Scriptエディタで「トリガー」アイコンをクリック + * 2. 「トリガーを追加」をクリック + * 3. 実行する関数: checkForNewJsonFiles + * 4. イベントのソース: 時間主導型 + * 5. 時間ベースのトリガーのタイプ: 分ベースのタイマー + * 6. 時間の間隔: 1分おき + */ +function checkForNewJsonFiles() { + try { + const folderId = CONFIG.DRIVE_FOLDER_ID; + Logger.log(`[DEBUG] フォルダID: ${folderId}`); + + if (!folderId) { + Logger.log('⚠ CONFIG.DRIVE_FOLDER_ID が設定されていません。'); + return; + } + + if (folderId === 'your_folder_id_here') { + Logger.log('⚠ CONFIG.DRIVE_FOLDER_ID がデフォルト値のままです。実際のフォルダIDを設定してください。'); + return; + } + + const folder = DriveApp.getFolderById(folderId); + Logger.log(`[DEBUG] フォルダ名: ${folder.getName()}`); + + const files = folder.getFiles(); // 全ファイルを取得 + + let fileCount = 0; + let processedCount = 0; + + while (files.hasNext()) { + const file = files.next(); + const fileName = file.getName(); + const mimeType = file.getMimeType(); + fileCount++; + + Logger.log(`[DEBUG] ファイル${fileCount}: ${fileName}`); + Logger.log(`[DEBUG] - MIMEタイプ: ${mimeType}`); + Logger.log(`[DEBUG] - .jsonで終わる? ${fileName.endsWith('.json')}`); + Logger.log(`[DEBUG] - processed/updated済み? ${fileName.startsWith('processed_') || fileName.startsWith('updated_')}`); + + // JSONファイルかチェック + if (!fileName.endsWith('.json')) { + Logger.log(`⏭ スキップ(JSONではない): ${fileName}`); + continue; + } + + // project_idを抽出 + const projectId = extractProjectId(fileName); + Logger.log(`[DEBUG] - プロジェクトID: ${projectId}`); + + // 処理条件: + // 1. processed_ または updated_ で始まらない(新規ファイル) + // 2. processed_ または updated_ で始まるが既存プロジェクト(更新ファイル) + const isProcessed = fileName.startsWith('processed_') || fileName.startsWith('updated_'); + const isNewFile = !isProcessed; + const isExistingProject = projectId && existsInProjectList(projectId); + + if (isNewFile || isExistingProject) { + // 既存プロジェクトかどうかで判定(こちらを優先) + if (isExistingProject) { + Logger.log(`✓ 既存プロジェクトの更新を検出: ${fileName}`); + } else { + Logger.log(`✓ 新しいJSONファイルを検出: ${fileName}`); + } + + try { + // JSONファイルを処理 + processJsonFile(file.getId()); + + // 処理成功後、ファイル名を変更 + if (isExistingProject) { + // 既存プロジェクトの場合は updated_ を付ける + const cleanFileName = fileName.replace(/^(processed_|updated_)/, ''); + const newFileName = 'updated_' + cleanFileName; + file.setName(newFileName); + Logger.log(`✓ 更新完了。ファイル名を変更: ${newFileName}`); + } else { + // 新規プロジェクトの場合は processed_ を付ける + const newFileName = 'processed_' + fileName; + file.setName(newFileName); + Logger.log(`✓ 新規作成完了。ファイル名を変更: ${newFileName}`); + } + + processedCount++; + } catch (error) { + Logger.log(`✗ ファイル処理エラー (${fileName}): ${error.message}`); + Logger.log(`✗ スタックトレース: ${error.stack}`); + // エラーが発生してもループは続ける + } + } else { + Logger.log(`⏭ スキップ: ${fileName}`); + } + } + + Logger.log(`[DEBUG] 検出ファイル数: ${fileCount}, 処理数: ${processedCount}`); + + if (processedCount > 0) { + Logger.log(`✓ ${processedCount}個のファイルを処理しました。`); + } else { + Logger.log('未処理のJSONファイルはありません。'); + } + + } catch (error) { + Logger.log('✗ フォルダ監視エラー: ' + error.message); + Logger.log('✗ スタックトレース: ' + error.stack); + sendDiscordNotification( + `Google Driveフォルダの監視中にエラーが発生しました。\n\nエラー内容: ${error.message}`, + true + ); + } +} + +/** + * 手動でJSONファイルを指定して処理 + * + * @param {string} fileId - Google DriveのファイルID + */ +function processJsonFile(fileId) { + try { + const file = DriveApp.getFileById(fileId); + const content = file.getBlob().getDataAsString(); + const projectData = JSON.parse(content); + + Logger.log(`JSONファイル「${file.getName()}」を処理中...`); + generateGanttFromData(projectData); + + sendDiscordNotification( + `プロジェクト「${projectData.project_name}」のGanttチャートを生成しました。\n` + + `ファイル: ${file.getName()}\n` + + `スプレッドシート: https://docs.google.com/spreadsheets/d/${CONFIG.SPREADSHEET_ID}` + ); + + } catch (error) { + Logger.log('✗ エラー: ' + error); + sendDiscordNotification( + `JSONファイル処理中にエラーが発生しました。\n\nエラー内容: ${error.message}`, + true + ); + throw error; + } +} + +/** + * 実際のJSONファイルから生成(Google Driveのファイル使用) + * + * 【自動実行】 + * - npm run gantt:save でアップロード時に自動的にprocessJsonFile()が実行されます + * - Apps Script APIを使用してNode.jsから直接呼び出されます + * + * 【手動実行】 + * 1. Google DriveにJSONファイルをアップロード + * 2. ファイルを右クリック → 「リンクを取得」 → ファイルIDをコピー + * 3. 下記の FILE_ID を実際のファイルIDに書き換え + * 4. この関数を実行 + * + * ファイルIDの例: "1a2b3c4d5e6f7g8h9i0j" (長い英数字の文字列) + */ +function generateGanttFromFile() { + const FILE_ID = 'your_file_id_here'; // ここに実際のファイルIDを入力 + + if (FILE_ID === 'your_file_id_here') { + Logger.log('⚠ FILE_ID を実際のファイルIDに書き換えてください。'); + return; + } + + processJsonFile(FILE_ID); +} + +/** + * 全Ganttシートを一括再同期 + * + * - 全Ganttシートからプロジェクトデータを読み取り + * - プロジェクト一覧、全タスクリスト、期日切れタスクを更新 + */ +function syncAllSheets() { + try { + Logger.log('=== 全Ganttシートの一括同期を開始 ==='); + + const sheetManager = new SheetManager(); + const ss = sheetManager.ss; + const allSheets = ss.getSheets(); + + // 1. 全Ganttシートを検出 + const ganttSheets = allSheets.filter(sheet => { + const name = sheet.getName(); + return name.match(/^\d{1,4}_.+$/); // "X_プロジェクト名" または "XXXX_プロジェクト名" 形式 + }); + + Logger.log(`✓ Ganttシート検出: ${ganttSheets.length}件`); + + if (ganttSheets.length === 0) { + Logger.log('⚠ Ganttシートが見つかりませんでした。'); + return; + } + + // 2. 各Ganttシートからデータを読み取って更新 + let successCount = 0; + let errorCount = 0; + + for (const ganttSheet of ganttSheets) { + try { + Logger.log(`--- ${ganttSheet.getName()} を同期中 ---`); + + // Ganttシートからプロジェクトデータを読み取り + const projectData = sheetManager.readProjectDataFromSheet(ganttSheet); + + if (!projectData) { + Logger.log(`⚠ データ読み取り失敗: ${ganttSheet.getName()}`); + errorCount++; + continue; + } + + // プロジェクト一覧を更新 + sheetManager.updateProjectList(projectData); + + // 全タスクリストを更新 + sheetManager.updateAllTasks(projectData); + + // Ganttシートのステータスから進捗率を更新 + sheetManager.updateGanttProgressFromStatus(ganttSheet); + + Logger.log(`✓ 同期完了: ${ganttSheet.getName()}`); + successCount++; + + // API制限・リソース枯渇対策:次のシート処理まで1秒待機 + Utilities.sleep(1000); + + } catch (error) { + Logger.log(`✗ 同期エラー (${ganttSheet.getName()}): ${error.message}`); + errorCount++; + } + } + + // 3. 期日切れタスクシートを更新 + Logger.log('--- 期日切れタスクシートを更新中 ---'); + try { + const overdueCount = sheetManager.updateOverdueTasks(); + Logger.log(`✓ 期日切れタスク更新完了: ${overdueCount}件`); + } catch (error) { + Logger.log(`✗ 期日切れタスク更新エラー: ${error.message}`); + } + + Logger.log('=== 同期完了 ==='); + Logger.log(`成功: ${successCount}件 / エラー: ${errorCount}件`); + + } catch (error) { + Logger.log('✗ 全体エラー: ' + error.message); + Logger.log('✗ スタックトレース: ' + error.stack); + throw error; + } +} + +/** + * Ganttシートをバッチ処理で同期 + * + * @param {number} startIndex - 開始インデックス(0始まり) + * @param {number} batchSize - 処理するシート数 + * @param {boolean} updateOverdue - 期日切れタスク更新を実行するか(デフォルト: false) + */ +function syncBatchSheets(startIndex, batchSize, updateOverdue = false) { + try { + Logger.log(`=== Ganttシートのバッチ同期を開始 (${startIndex}~${startIndex + batchSize - 1}) ===`); + + const sheetManager = new SheetManager(); + const ss = sheetManager.ss; + const allSheets = ss.getSheets(); + + // 1. 全Ganttシートを検出 + const ganttSheets = allSheets.filter(sheet => { + const name = sheet.getName(); + return name.match(/^\d{1,4}_.+$/); // "X_プロジェクト名" または "XXXX_プロジェクト名" 形式 + }); + + Logger.log(`✓ Ganttシート検出: ${ganttSheets.length}件`); + + if (ganttSheets.length === 0) { + Logger.log('⚠ Ganttシートが見つかりませんでした。'); + return; + } + + // 2. バッチ範囲を計算 + const endIndex = Math.min(startIndex + batchSize, ganttSheets.length); + const batchSheets = ganttSheets.slice(startIndex, endIndex); + + Logger.log(`✓ バッチ処理対象: ${batchSheets.length}件 (${startIndex}~${endIndex - 1})`); + + if (batchSheets.length === 0) { + Logger.log('⚠ 処理対象のシートがありません。'); + return; + } + + // 3. 各Ganttシートからデータを読み取って更新 + let successCount = 0; + let errorCount = 0; + + for (const ganttSheet of batchSheets) { + try { + Logger.log(`--- ${ganttSheet.getName()} を同期中 ---`); + + // Ganttシートからプロジェクトデータを読み取り + const projectData = sheetManager.readProjectDataFromSheet(ganttSheet); + + if (!projectData) { + Logger.log(`⚠ データ読み取り失敗: ${ganttSheet.getName()}`); + errorCount++; + continue; + } + + // プロジェクト一覧を更新 + sheetManager.updateProjectList(projectData); + + // 全タスクリストを更新 + sheetManager.updateAllTasks(projectData); + + // Ganttシートのステータスから進捗率を更新 + sheetManager.updateGanttProgressFromStatus(ganttSheet); + + Logger.log(`✓ 同期完了: ${ganttSheet.getName()}`); + successCount++; + + // API制限・リソース枯渇対策:次のシート処理まで1秒待機 + Utilities.sleep(1000); + + } catch (error) { + Logger.log(`✗ 同期エラー (${ganttSheet.getName()}): ${error.message}`); + errorCount++; + } + } + + // 4. 期日切れタスクシートを更新(最後のバッチのみ) + if (updateOverdue) { + Logger.log('--- 期日切れタスクシートを更新中 ---'); + try { + const overdueCount = sheetManager.updateOverdueTasks(); + Logger.log(`✓ 期日切れタスク更新完了: ${overdueCount}件`); + } catch (error) { + Logger.log(`✗ 期日切れタスク更新エラー: ${error.message}`); + } + } + + Logger.log('=== バッチ同期完了 ==='); + Logger.log(`成功: ${successCount}件 / エラー: ${errorCount}件`); + + } catch (error) { + Logger.log('✗ バッチ全体エラー: ' + error.message); + Logger.log('✗ スタックトレース: ' + error.stack); + throw error; + } +} + +/** + * バッチ1: 0-4番目のシートを同期 + */ +function syncBatch1() { + syncBatchSheets(0, 5); +} + +/** + * バッチ2: 5-9番目のシートを同期 + */ +function syncBatch2() { + syncBatchSheets(5, 5); +} + +/** + * バッチ3: 10-14番目のシートを同期 + */ +function syncBatch3() { + syncBatchSheets(10, 5); +} + +/** + * バッチ4: 15-19番目のシートを同期 + */ +function syncBatch4() { + syncBatchSheets(15, 5); +} + +/** + * バッチ5: 20番目以降のシートを同期 + 期日切れタスク更新 + */ +function syncBatch5() { + syncBatchSheets(20, 5, true); // 最後のバッチのみ期日切れタスク更新 +} + +/** + * 自動継続方式のバッチ同期 + * - プロパティサービスで進捗を保存 + * - 前回の続きから5シートずつ処理 + * - 全シート完了したら最初に戻る + * - 最後のバッチで期日切れタスク更新 + */ +function syncNextBatch() { + try { + const scriptProperties = PropertiesService.getScriptProperties(); + const BATCH_SIZE = 5; + + // 現在のインデックスを取得(初回は0) + let currentIndex = parseInt(scriptProperties.getProperty('SYNC_CURRENT_INDEX') || '0'); + + Logger.log(`=== 自動継続バッチ同期開始 (開始インデックス: ${currentIndex}) ===`); + + const sheetManager = new SheetManager(); + const ss = sheetManager.ss; + const allSheets = ss.getSheets(); + + // Ganttシートを検出 + const ganttSheets = allSheets.filter(sheet => { + const name = sheet.getName(); + return name.match(/^\d{1,4}_.+$/); + }); + + const totalSheets = ganttSheets.length; + Logger.log(`✓ 総Ganttシート数: ${totalSheets}件`); + + if (totalSheets === 0) { + Logger.log('⚠ Ganttシートが見つかりませんでした。'); + scriptProperties.setProperty('SYNC_CURRENT_INDEX', '0'); + return; + } + + // インデックスが範囲外の場合はリセット + if (currentIndex >= totalSheets) { + Logger.log('✓ 全シート処理完了。最初に戻ります。'); + currentIndex = 0; + } + + // バッチ範囲を計算 + const endIndex = Math.min(currentIndex + BATCH_SIZE, totalSheets); + const batchSheets = ganttSheets.slice(currentIndex, endIndex); + const isLastBatch = endIndex >= totalSheets; + + Logger.log(`✓ バッチ処理: ${batchSheets.length}件 (${currentIndex}~${endIndex - 1}) ${isLastBatch ? '[最終バッチ]' : ''}`); + + // 各シートを同期 + let successCount = 0; + let errorCount = 0; + + for (const ganttSheet of batchSheets) { + try { + Logger.log(`--- ${ganttSheet.getName()} を同期中 ---`); + + const projectData = sheetManager.readProjectDataFromSheet(ganttSheet); + + if (!projectData) { + Logger.log(`⚠ データ読み取り失敗: ${ganttSheet.getName()}`); + errorCount++; + continue; + } + + sheetManager.updateProjectList(projectData); + sheetManager.updateAllTasks(projectData); + sheetManager.updateGanttProgressFromStatus(ganttSheet); + + Logger.log(`✓ 同期完了: ${ganttSheet.getName()}`); + successCount++; + + Utilities.sleep(1000); + + } catch (error) { + Logger.log(`✗ 同期エラー (${ganttSheet.getName()}): ${error.message}`); + errorCount++; + } + } + + // 最終バッチの場合は期日切れタスク更新 + if (isLastBatch) { + Logger.log('--- 期日切れタスクシートを更新中 ---'); + try { + const overdueCount = sheetManager.updateOverdueTasks(); + Logger.log(`✓ 期日切れタスク更新完了: ${overdueCount}件`); + } catch (error) { + Logger.log(`✗ 期日切れタスク更新エラー: ${error.message}`); + } + } + + // 次回のインデックスを保存 + const nextIndex = isLastBatch ? 0 : endIndex; + scriptProperties.setProperty('SYNC_CURRENT_INDEX', nextIndex.toString()); + + Logger.log('=== バッチ同期完了 ==='); + Logger.log(`成功: ${successCount}件 / エラー: ${errorCount}件`); + Logger.log(`次回開始インデックス: ${nextIndex} ${isLastBatch ? '(最初に戻る)' : ''}`); + + } catch (error) { + Logger.log('✗ バッチ全体エラー: ' + error.message); + Logger.log('✗ スタックトレース: ' + error.stack); + throw error; + } +} + +/** + * 全Ganttシートを強制的に再同期 + * (変更検知機能を削除したため、syncAllSheets()と同じ) + */ +function forceResyncAll() { + syncAllSheets(); +} + +/** + * 全Ganttシートの期日切れタスクに色付け + * - ステータスには一切触らない + * - 終了日セル(G列)のみ色付け(赤背景・黄色文字・太字) + * - render()を使わないので超軽量 + */ +function updateOverdueColors() { + try { + Logger.log('=== 期日切れタスクの色付け開始 ==='); + + const sheetManager = new SheetManager(); + const ss = sheetManager.ss; + const allSheets = ss.getSheets(); + + // Ganttシートを検出 + const ganttSheets = allSheets.filter(sheet => { + const name = sheet.getName(); + return name.match(/^\d{1,4}_.+$/); + }); + + Logger.log(`✓ Ganttシート検出: ${ganttSheets.length}件`); + + if (ganttSheets.length === 0) { + Logger.log('⚠ Ganttシートが見つかりませんでした。'); + return; + } + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + let totalColoredCells = 0; + + for (const ganttSheet of ganttSheets) { + try { + // タスクデータの開始行(GanttRendererの構造: 2行目がヘッダー、4行目からタスク) + const lastRow = ganttSheet.getLastRow(); + if (lastRow < 4) continue; + + const taskStartRow = 4; // 固定(4行目からタスクデータ) + + // タスクデータの範囲を取得 + const taskDataRange = ganttSheet.getRange(taskStartRow, 1, lastRow - taskStartRow + 1, 9); + const taskData = taskDataRange.getValues(); + + // 全終了日セル(G列)をリセット + const endDateCol = ganttSheet.getRange(taskStartRow, 7, taskData.length, 1); + endDateCol.setBackground('#FFFFFF'); + endDateCol.setFontColor('#000000'); + endDateCol.setFontWeight('normal'); + + let coloredCount = 0; + + // 各タスクをチェック + for (let i = 0; i < taskData.length; i++) { + const row = taskData[i]; + const endDateValue = row[6]; // G列(終了日) + const progress = row[7]; // H列(進捗率) + + // 空行はスキップ + if (!row[0]) continue; + + // 期日切れ判定(進捗率は0.0-1.0の小数で保存されている) + if (endDateValue && progress !== 1) { + const endDate = new Date(endDateValue); + endDate.setHours(0, 0, 0, 0); + + if (endDate < today) { + // 期日切れ → 色付け + const endDateCell = ganttSheet.getRange(taskStartRow + i, 7); + endDateCell.setBackground('#FF0000'); // 赤背景 + endDateCell.setFontColor('#FFFF00'); // 黄色文字 + endDateCell.setFontWeight('bold'); // 太字 + coloredCount++; + } + } + } + + if (coloredCount > 0) { + Logger.log(`✓ ${ganttSheet.getName()}: ${coloredCount}件の期日切れタスクに色付け`); + } + totalColoredCells += coloredCount; + + } catch (error) { + Logger.log(`✗ 色付けエラー (${ganttSheet.getName()}): ${error.message}`); + } + } + + Logger.log('=== 色付け完了 ==='); + Logger.log(`総計: ${totalColoredCells}件の期日切れタスクに色付け`); + + } catch (error) { + Logger.log('✗ 全体エラー: ' + error.message); + Logger.log('✗ スタックトレース: ' + error.stack); + throw error; + } +} + +/** + * 期日切れタスクをDiscordに通知 + * + * - 期日切れタスクシートのデータを読み取り + * - Discord WebhookでEmbed形式の通知を送信 + */ +function sendOverdueTasksNotification() { + try { + const sheetManager = new SheetManager(); + const overdueSheet = sheetManager.getOrCreateOverdueTasksSheet(); + + // 期日切れタスクを取得 + const lastRow = overdueSheet.getLastRow(); + if (lastRow <= 1) { + Logger.log('✓ 期日切れタスクはありません。'); + return; + } + + // データを読み取り(ヘッダー除く) + const data = overdueSheet.getRange(2, 1, lastRow - 1, 9).getValues(); + + // プロジェクトごとにグループ化 + const projectGroups = {}; + for (const row of data) { + const projectId = String(row[0] || ''); + const projectName = String(row[1] || ''); + const taskId = String(row[2] || ''); + const parentTaskName = String(row[3] || ''); + const childTaskName = String(row[4] || ''); + const dueDate = row[5] || ''; + const assignee = String(row[6] || ''); + const daysOverdue = row[7] || 0; + const progress = row[8] || 0; + + // タスク名を決定 + const taskName = childTaskName || parentTaskName || taskId; + + // 進捗率を正規化(0.0-1.0 → 0-100) + let progressPercent = 0; + if (typeof progress === 'number') { + progressPercent = progress <= 1 ? Math.round(progress * 100) : Math.round(progress); + } + + if (!projectGroups[projectId]) { + projectGroups[projectId] = { + projectName: projectName, + tasks: [] + }; + } + + projectGroups[projectId].tasks.push({ + taskName: taskName, + dueDate: dueDate, + assignee: assignee, + daysOverdue: daysOverdue, + progress: progressPercent + }); + } + + // Embed形式でDiscord通知を作成 + const embeds = []; + + for (const projectId in projectGroups) { + const group = projectGroups[projectId]; + + // タスクリストをMarkdown形式で整形 + const taskList = group.tasks + .slice(0, 10) // 最大10件まで + .map(task => { + const dueDateStr = task.dueDate instanceof Date + ? `${task.dueDate.getMonth() + 1}/${task.dueDate.getDate()}` + : task.dueDate; + return `• **${task.taskName}** (期日: ${dueDateStr}, 遅延: ${task.daysOverdue}日, 進捗: ${task.progress}%)`; + }) + .join('\n'); + + const moreCount = group.tasks.length > 10 ? `\n... 他${group.tasks.length - 10}件` : ''; + + embeds.push({ + title: `⚠️ 期日切れタスク: ${group.projectName}`, + description: taskList + moreCount, + color: 0xFF6B6B, // 赤色 + footer: { + text: `合計 ${group.tasks.length}件の期日切れタスク` + } + }); + } + + if (embeds.length === 0) { + Logger.log('✓ 期日切れタスクはありません。'); + return; + } + + // Discord Webhook送信 + const payload = { embeds: embeds }; + const options = { + method: 'post', + contentType: 'application/json', + payload: JSON.stringify(payload), + muteHttpExceptions: true + }; + + const response = UrlFetchApp.fetch(CONFIG.DISCORD_WEBHOOK_URL, options); + Logger.log(`✓ Discord通知送信完了: ${response.getResponseCode()}`); + + } catch (error) { + Logger.log(`✗ Discord通知エラー: ${error.message}`); + Logger.log(`✗ スタックトレース: ${error.stack}`); + throw error; + } +} + +/** + * TodoistのInboxタスクを同期 + * + * - Todoist REST API v2を使用してInboxタスクを取得 + * - Todoistタスクシートに書き込み + */ +function syncTodoistTasks() { + try { + Logger.log('=== Todoistタスク同期開始 ==='); + + // 1. Todoist APIからタスクを取得 + const apiKey = CONFIG.TODOIST_API_KEY; + if (!apiKey) { + Logger.log('⚠ TODOIST_API_KEY が設定されていません。'); + return 0; + } + + const url = 'https://api.todoist.com/rest/v2/tasks'; + const options = { + method: 'get', + headers: { + 'Authorization': `Bearer ${apiKey}` + }, + muteHttpExceptions: true + }; + + const response = UrlFetchApp.fetch(url, options); + const responseCode = response.getResponseCode(); + + if (responseCode !== 200) { + Logger.log(`✗ Todoist API エラー: ${responseCode}`); + Logger.log(`✗ レスポンス: ${response.getContentText()}`); + return 0; + } + + const allTasks = JSON.parse(response.getContentText()); + Logger.log(`✓ Todoistタスク取得完了: ${allTasks.length}件`); + + // 2. プロジェクト一覧を取得してInboxプロジェクトIDを特定 + const projectsUrl = 'https://api.todoist.com/rest/v2/projects'; + const projectsResponse = UrlFetchApp.fetch(projectsUrl, options); + const projects = JSON.parse(projectsResponse.getContentText()); + + let inboxProjectId = null; + for (const project of projects) { + if (project.name === 'Inbox' || project.is_inbox_project) { + inboxProjectId = String(project.id); + Logger.log(`✓ Inboxプロジェクト検出: ${inboxProjectId}`); + break; + } + } + + if (!inboxProjectId) { + Logger.log('⚠ InboxプロジェクトIDが見つかりませんでした。'); + return 0; + } + + // 3. Inboxプロジェクトのタスクのみをフィルタリング + const inboxTasks = allTasks.filter(task => String(task.project_id) === inboxProjectId); + Logger.log(`✓ Inboxタスク抽出完了: ${inboxTasks.length}件`); + + // 4. シートマネージャーを使用してタスクを更新 + const sheetManager = new SheetManager(); + const count = sheetManager.updateTodoistTasks(inboxTasks); + + Logger.log(`✓ Todoistタスク同期完了: ${count}件`); + return count; + + } catch (error) { + Logger.log(`✗ Todoistタスク同期エラー: ${error.message}`); + Logger.log(`✗ スタックトレース: ${error.stack}`); + throw error; + } +} + +/** + * Todoist連携機能のテスト(開発用) + * + * 使用方法: + * 1. GASエディタでこの関数を選択 + * 2. 実行ボタンをクリック + * 3. 以下がテストされます: + * - Todoist APIからInboxタスクを取得 + * - Todoistタスクシートへの書き込み + * - Discord通知の送信 + */ +/** + * 既存シートに実績工数列を追加するマイグレーション関数 + * + * 【重要】この関数は一度だけ実行してください。 + * + * 処理内容: + * 1. 全Ganttシート(X_プロジェクト名形式)の12列目(L列)に「実績工数(h)」列を挿入 + * 2. 全プロジェクトタスクシートの14列目(N列)に「実績工数(h)」列を挿入 + * + * 使用方法: + * 1. GASエディタでこの関数を選択 + * 2. 実行ボタンをクリック + * 3. 実行後、各シートに「実績工数(h)」列が追加されたことを確認 + */ +function migrateExistingSheetsForActualHours() { + try { + Logger.log('=== 実績工数列マイグレーション開始 ==='); + + const sheetManager = new SheetManager(); + const ss = sheetManager.ss; + const allSheets = ss.getSheets(); + + let ganttCount = 0; + let errorCount = 0; + + // 1. 全Ganttシートを処理 + Logger.log('--- Ganttシートを処理中 ---'); + + for (const sheet of allSheets) { + const sheetName = sheet.getName(); + + // Ganttシート(X_プロジェクト名 または XXXX_プロジェクト名形式)を検出 + if (sheetName.match(/^\d{1,4}_.+$/)) { + try { + Logger.log(`処理中: ${sheetName}`); + + // 2行目(ヘッダー行)の12列目(L列)の値を確認 + const currentHeader = sheet.getRange(2, 12).getValue(); + + // すでに「実績工数(h)」がある場合はスキップ + if (currentHeader === '実績工数(h)') { + Logger.log(` ⏭ スキップ(すでに実績工数列あり): ${sheetName}`); + continue; + } + + // 12列目(L列)に新しい列を挿入 + sheet.insertColumnAfter(11); // K列の後ろに挿入 + + // ヘッダー行(2行目)の12列目に「実績工数(h)」を設定 + sheet.getRange(2, 12).setValue('実績工数(h)'); + + // ヘッダーのスタイルを適用(他のヘッダーと同じスタイル) + const headerCell = sheet.getRange(2, 12); + headerCell.setBackground('#E8F4FD'); + headerCell.setHorizontalAlignment('center'); + headerCell.setVerticalAlignment('middle'); + + // 1行目の12列目も同じ色で塗る + sheet.getRange(1, 12).setBackground('#E8F4FD'); + sheet.getRange(1, 12).setHorizontalAlignment('center'); + sheet.getRange(1, 12).setVerticalAlignment('middle'); + + Logger.log(` ✓ 完了: ${sheetName}`); + ganttCount++; + + } catch (error) { + Logger.log(` ✗ エラー (${sheetName}): ${error.message}`); + errorCount++; + } + } + } + + Logger.log(`✓ Ganttシート処理完了: ${ganttCount}件`); + + // 2. 全プロジェクトタスクシートを処理 + Logger.log('--- 全プロジェクトタスクシートを処理中 ---'); + + const allTasksSheet = ss.getSheetByName(CONFIG.SHEET_NAMES.ALL_TASKS); + + if (allTasksSheet) { + try { + // 1行目(ヘッダー行)の14列目(N列)の値を確認 + const currentHeader = allTasksSheet.getRange(1, 14).getValue(); + + // すでに「実績工数(h)」がある場合はスキップ + if (currentHeader === '実績工数(h)') { + Logger.log(' ⏭ スキップ(すでに実績工数列あり): 全プロジェクトタスク'); + } else { + // 14列目(N列)に新しい列を挿入 + allTasksSheet.insertColumnAfter(13); // M列の後ろに挿入 + + // ヘッダー行(1行目)の14列目に「実績工数(h)」を設定 + allTasksSheet.getRange(1, 14).setValue('実績工数(h)'); + + // ヘッダーのスタイルを適用 + const headerCell = allTasksSheet.getRange(1, 14); + headerCell.setBackground('#4A90E2'); + headerCell.setFontColor('#FFFFFF'); + headerCell.setFontWeight('bold'); + headerCell.setHorizontalAlignment('center'); + + // 列幅を設定 + allTasksSheet.setColumnWidth(14, 75); + + Logger.log(' ✓ 完了: 全プロジェクトタスク'); + } + } catch (error) { + Logger.log(` ✗ エラー (全プロジェクトタスク): ${error.message}`); + errorCount++; + } + } else { + Logger.log(' ⚠ 全プロジェクトタスクシートが見つかりません。'); + } + + Logger.log('=== マイグレーション完了 ==='); + Logger.log(`成功: ${ganttCount}件のGanttシート + 全タスクシート / エラー: ${errorCount}件`); + + // Discord通知 + sendDiscordNotification( + `✅ **実績工数列マイグレーション完了**\n\n` + + `Ganttシート: ${ganttCount}件\n` + + `全プロジェクトタスクシート: 処理済み\n` + + `エラー: ${errorCount}件`, + false + ); + + } catch (error) { + Logger.log('✗ マイグレーションエラー: ' + error.message); + Logger.log('✗ スタックトレース: ' + error.stack); + sendDiscordNotification( + `実績工数列マイグレーション中にエラーが発生しました。\n\nエラー内容: ${error.message}`, + true + ); + throw error; + } +} + +/** + * 依存タスクブロッカー検知: 遅延タスクの影響分析 + * + * この関数は遅延しているタスクを検出し、それらが他のタスクに与える影響を分析します。 + * + * @param {Object} projectData - プロジェクトデータ + * @return {Object} 分析結果 + * { + * delayedTasks: [遅延タスクの配列], + * impactedTasks: { + * delayed_task_id: { + * impactedTaskIds: [影響を受けるタスクIDの配列], + * criticalPathLength: クリティカルパスの長さ + * } + * }, + * circularDependencies: [[循環依存タスクIDの配列], ...], + * taskMap: { task_id: task_object } + * } + */ +function analyzeDependencyImpact(projectData) { + try { + const today = new Date(); + today.setHours(0, 0, 0, 0); // 時刻をリセット + + const tasks = projectData.tasks || []; + const delayedTasks = []; + const taskMap = {}; // task_id -> task object + const dependencyGraph = {}; // task_id -> [dependent task_ids] (このタスクに依存しているタスクのID) + + // 1. タスクマップを構築 & 遅延タスクを抽出 + Logger.log('--- 遅延タスクを検出中 ---'); + for (const task of tasks) { + taskMap[task.task_id] = task; + + // 遅延タスク判定: 終了日 < 今日 && 進捗率 < 100% + if (task.end_date) { + const endDate = new Date(task.end_date); + endDate.setHours(0, 0, 0, 0); + + const progress = task.progress || 0; + const progressPercent = progress <= 1 ? progress * 100 : progress; + + if (endDate < today && progressPercent < 100) { + const daysOverdue = Math.floor((today - endDate) / (1000 * 60 * 60 * 24)); + delayedTasks.push({ + ...task, + daysOverdue: daysOverdue + }); + Logger.log(` 遅延タスク検出: ${task.task_id} (${task.task_name}) - ${daysOverdue}日遅延`); + } + } + } + + Logger.log(`✓ 遅延タスク: ${delayedTasks.length}件`); + + // 2. 依存関係グラフを構築(逆方向: どのタスクが自分に依存しているか) + Logger.log('--- 依存関係グラフを構築中 ---'); + for (const task of tasks) { + const dependencies = task.dependencies || []; + for (const depTaskId of dependencies) { + if (!dependencyGraph[depTaskId]) { + dependencyGraph[depTaskId] = []; + } + dependencyGraph[depTaskId].push(task.task_id); + } + } + + // 3. 各遅延タスクについて影響範囲を分析(DFS: 深さ優先探索) + Logger.log('--- 影響範囲を分析中 ---'); + const impactedTasks = {}; // delayed_task_id -> { impactedTaskIds: [...], criticalPathLength: N } + + for (const delayedTask of delayedTasks) { + const visited = new Set(); + const impactedTaskIds = []; + let maxDepth = 0; + + // DFSで影響範囲を探索 + const dfs = (taskId, depth) => { + if (visited.has(taskId)) { + return; // 既に訪問済み(循環依存の場合) + } + + visited.add(taskId); + maxDepth = Math.max(maxDepth, depth); + + // 遅延タスク自身は除外 + if (taskId !== delayedTask.task_id) { + impactedTaskIds.push(taskId); + } + + // このタスクに依存しているタスクを再帰的に探索 + const dependentTasks = dependencyGraph[taskId] || []; + for (const dependentTaskId of dependentTasks) { + dfs(dependentTaskId, depth + 1); + } + }; + + dfs(delayedTask.task_id, 0); + + impactedTasks[delayedTask.task_id] = { + impactedTaskIds: impactedTaskIds, + criticalPathLength: maxDepth + }; + + Logger.log(` ${delayedTask.task_id}: ${impactedTaskIds.length}件のタスクに影響, クリティカルパス長=${maxDepth}`); + } + + // 4. 循環依存を検出 + Logger.log('--- 循環依存を検出中 ---'); + const circularDependencies = detectCircularDependencies(tasks); + + if (circularDependencies.length > 0) { + Logger.log(`⚠ 循環依存を検出: ${circularDependencies.length}件`); + for (const cycle of circularDependencies) { + Logger.log(` 循環: ${cycle.join(' -> ')}`); + } + } else { + Logger.log('✓ 循環依存なし'); + } + + return { + delayedTasks: delayedTasks, + impactedTasks: impactedTasks, + circularDependencies: circularDependencies, + taskMap: taskMap + }; + + } catch (error) { + Logger.log(`✗ 依存関係分析エラー: ${error.message}`); + Logger.log(`✗ スタックトレース: ${error.stack}`); + throw error; + } +} + +/** + * 循環依存を検出 + * + * タスク間の依存関係に循環がないかをチェックします。 + * 循環依存がある場合、それらのタスクIDのサイクルを返します。 + * + * @param {Array} tasks - タスク配列 + * @return {Array} 循環依存のリスト [[taskId1, taskId2, ...], ...] + */ +function detectCircularDependencies(tasks) { + const circularDeps = []; + const visited = new Set(); + const recursionStack = new Set(); + const taskMap = {}; + + // タスクマップを構築 + for (const task of tasks) { + taskMap[task.task_id] = task; + } + + // DFSで循環依存を検出 + const dfs = (taskId, path) => { + if (recursionStack.has(taskId)) { + // 循環依存を発見 + const cycleStart = path.indexOf(taskId); + const cycle = path.slice(cycleStart).concat([taskId]); + circularDeps.push(cycle); + return true; + } + + if (visited.has(taskId)) { + return false; + } + + visited.add(taskId); + recursionStack.add(taskId); + path.push(taskId); + + const task = taskMap[taskId]; + if (task && task.dependencies) { + for (const depTaskId of task.dependencies) { + dfs(depTaskId, path.slice()); + } + } + + recursionStack.delete(taskId); + return false; + }; + + // 全タスクをチェック + for (const task of tasks) { + if (!visited.has(task.task_id)) { + dfs(task.task_id, []); + } + } + + return circularDeps; +} + +/** + * 依存タスクブロッカーの通知をDiscordに送信 + * + * 遅延タスクとその影響範囲をEmbed形式でDiscordに通知します。 + * + * @param {Object} impactReport - analyzeDependencyImpact()の戻り値 + * @param {Object} projectData - プロジェクトデータ + * @param {Object} criticalPathReport - calculateCriticalPath()の戻り値(オプション) + */ +function sendDependencyBlockerNotification(impactReport, projectData, criticalPathReport) { + try { + if (!impactReport.delayedTasks || impactReport.delayedTasks.length === 0) { + Logger.log('✓ 遅延タスクがないため通知をスキップ'); + return; + } + + const embeds = []; + + // プロジェクト情報 + const projectName = projectData.project_name || 'プロジェクト'; + const projectId = projectData.project_id || ''; + + // 遅延タスク情報を整形(最大5件まで表示) + const delayedTasksInfo = impactReport.delayedTasks + .slice(0, 5) + .map(task => { + const taskName = task.task_name || task.task_id; + const daysOverdue = task.daysOverdue || 0; + const impact = impactReport.impactedTasks[task.task_id]; + const impactCount = impact ? impact.impactedTaskIds.length : 0; + const criticalPathLength = impact ? impact.criticalPathLength : 0; + + return `• **${taskName}**\n - 遅延: ${daysOverdue}日\n - 影響タスク数: ${impactCount}件\n - クリティカルパス長: ${criticalPathLength}`; + }) + .join('\n\n'); + + const moreCount = impactReport.delayedTasks.length > 5 + ? `\n\n... 他${impactReport.delayedTasks.length - 5}件の遅延タスク` + : ''; + + // 循環依存情報 + let circularDepsInfo = ''; + if (impactReport.circularDependencies.length > 0) { + circularDepsInfo = '\n\n⚠️ **循環依存を検出**\n' + + impactReport.circularDependencies + .slice(0, 3) + .map(cycle => `• ${cycle.join(' → ')}`) + .join('\n'); + + if (impactReport.circularDependencies.length > 3) { + circularDepsInfo += `\n... 他${impactReport.circularDependencies.length - 3}件`; + } + } + + const embed = { + title: `⚠️ 依存タスクブロッカー検知: ${projectName}`, + description: delayedTasksInfo + moreCount + circularDepsInfo, + color: 0xFF6B6B, // 赤色 + footer: { + text: `合計 ${impactReport.delayedTasks.length}件の遅延タスク` + }, + timestamp: new Date().toISOString() + }; + + // クリティカルパス情報を追加 + if (criticalPathReport && !criticalPathReport.skipped && !criticalPathReport.error) { + embed.fields = [{ + name: '🎯 クリティカルパス情報', + value: `プロジェクト所要時間: **${criticalPathReport.projectDuration}日**\n` + + `クリティカルタスク数: **${criticalPathReport.criticalTasks.length}件**\n` + + `Near-Criticalタスク数: **${criticalPathReport.nearCriticalTasks.length}件**`, + inline: false + }]; + } + + embeds.push(embed); + + // Discord Webhook送信 + const payload = { embeds: embeds }; + const options = { + method: 'post', + contentType: 'application/json', + payload: JSON.stringify(payload), + muteHttpExceptions: true + }; + + const response = UrlFetchApp.fetch(CONFIG.DISCORD_WEBHOOK_URL, options); + const responseCode = response.getResponseCode(); + + if (responseCode === 200 || responseCode === 204) { + Logger.log(`✓ Discord通知送信完了: ${responseCode}`); + } else { + Logger.log(`⚠ Discord通知送信エラー: ${responseCode}`); + Logger.log(`レスポンス: ${response.getContentText()}`); + } + + } catch (error) { + Logger.log(`✗ Discord通知エラー: ${error.message}`); + Logger.log(`✗ スタックトレース: ${error.stack}`); + throw error; + } +} + +/** + * 依存タスクブロッカーを自動チェック(トリガー用) + * + * すべてのGanttシートで遅延タスクの影響を分析し、Discord通知とハイライトを実行します。 + * + * トリガー設定: + * - 頻度: 毎日9:00に実行 + * - 設定方法: Apps Scriptエディタの「トリガー」から設定 + */ +function checkDependencyBlockers() { + try { + Logger.log('=== 依存タスクブロッカーチェック開始 ==='); + + const sheetManager = new SheetManager(); + const ss = sheetManager.ss; + const allSheets = ss.getSheets(); + + // Ganttシートを検出 + const ganttSheets = allSheets.filter(sheet => { + const name = sheet.getName(); + return name.match(/^\d{1,4}_.+$/); // "X_プロジェクト名" または "XXXX_プロジェクト名" 形式 + }); + + Logger.log(`✓ Ganttシート検出: ${ganttSheets.length}件`); + + if (ganttSheets.length === 0) { + Logger.log('⚠ Ganttシートが見つかりませんでした。'); + return; + } + + let totalProjects = 0; + let totalDelayed = 0; + let totalImpacted = 0; + let errorCount = 0; + + // 各Ganttシートをチェック + for (const ganttSheet of ganttSheets) { + try { + Logger.log(`--- ${ganttSheet.getName()} をチェック中 ---`); + + // Ganttシートからプロジェクトデータを読み取り + const projectData = sheetManager.readProjectDataFromSheet(ganttSheet); + + if (!projectData) { + Logger.log(`⚠ データ読み取り失敗: ${ganttSheet.getName()}`); + errorCount++; + continue; + } + + // 依存関係の影響を分析 + const impactReport = analyzeDependencyImpact(projectData); + + // クリティカルパス分析 + const criticalPathReport = calculateCriticalPath(projectData); + if (criticalPathReport && !criticalPathReport.skipped) { + Logger.log(`[INFO] Critical path calculated for ${ganttSheet.getName()}: ${criticalPathReport.projectDuration} days`); + } + + if (impactReport.delayedTasks.length > 0) { + // Discord通知を送信(クリティカルパス情報を含む) + sendDependencyBlockerNotification(impactReport, projectData, criticalPathReport); + + // Ganttシートでブロックタスクをハイライト + highlightBlockedTasks(ganttSheet, projectData, impactReport); + + // クリティカルパスをハイライト + highlightCriticalPath(ganttSheet, projectData, criticalPathReport); + + totalDelayed += impactReport.delayedTasks.length; + + // 影響を受けるタスク数を集計 + for (const delayedTaskId in impactReport.impactedTasks) { + totalImpacted += impactReport.impactedTasks[delayedTaskId].impactedTaskIds.length; + } + + Logger.log(`✓ ${ganttSheet.getName()}: ${impactReport.delayedTasks.length}件の遅延タスク検出`); + } else { + Logger.log(`✓ ${ganttSheet.getName()}: 遅延タスクなし`); + } + + totalProjects++; + + } catch (error) { + Logger.log(`✗ チェックエラー (${ganttSheet.getName()}): ${error.message}`); + errorCount++; + } + } + + Logger.log('=== チェック完了 ==='); + Logger.log(`プロジェクト: ${totalProjects}件 / 遅延タスク: ${totalDelayed}件 / 影響タスク: ${totalImpacted}件 / エラー: ${errorCount}件`); + + // サマリー通知(遅延タスクがある場合のみ) + if (totalDelayed > 0) { + sendDiscordNotification( + `📊 **依存タスクブロッカーチェック完了**\n\n` + + `チェック対象: ${totalProjects}件のプロジェクト\n` + + `遅延タスク: ${totalDelayed}件\n` + + `影響を受けるタスク: ${totalImpacted}件\n` + + `エラー: ${errorCount}件`, + errorCount > 0 + ); + } + + } catch (error) { + Logger.log('✗ 全体エラー: ' + error.message); + Logger.log('✗ スタックトレース: ' + error.stack); + sendDiscordNotification( + `依存タスクブロッカーチェック中にエラーが発生しました。\n\nエラー内容: ${error.message}`, + true + ); + throw error; + } +} + +/** + * クリティカルパスを計算 + * + * タスクの依存関係からクリティカルパス(最長経路)を算出します。 + * Kahn's Algorithmによるトポロジカルソート + Forward/Backward Passで計算。 + * + * @param {Object} projectData - プロジェクトデータ(tasks配列を含む) + * @return {Object} クリティカルパス情報 + * { + * criticalTasks: [task_id, ...], // クリティカルパス上のタスクID配列 + * taskMetrics: { + * task_id: { + * es: Number, // 最早開始時刻(日数) + * ef: Number, // 最早終了時刻(日数) + * ls: Number, // 最遅開始時刻(日数) + * lf: Number, // 最遅終了時刻(日数) + * slack: Number // スラック(日数) + * } + * }, + * nearCriticalTasks: [task_id, ...], // Near-critical(Slack 1-2日) + * projectDuration: Number // プロジェクト全体の所要日数 + * } + */ +function calculateCriticalPath(projectData) { + const tasks = projectData.tasks; + + // パフォーマンス保護: タスク数が多すぎる場合はスキップ + if (tasks.length > 500) { + Logger.log('[WARNING] Too many tasks (' + tasks.length + '), skipping critical path analysis'); + return { + criticalTasks: [], + taskMetrics: {}, + nearCriticalTasks: [], + projectDuration: 0, + skipped: true + }; + } + + // 1. タスクマップ構築(ID → タスクオブジェクト) + const taskMap = {}; + for (const task of tasks) { + taskMap[task.task_id] = task; + } + + // 2. グラフ構築(隣接リスト形式) + const adjacencyList = {}; // task_id → [dependent_task_ids] + const inDegree = {}; // task_id → 入次数 + + for (const task of tasks) { + adjacencyList[task.task_id] = []; + inDegree[task.task_id] = 0; + } + + for (const task of tasks) { + if (task.dependencies && task.dependencies.length > 0) { + for (const depId of task.dependencies) { + if (adjacencyList[depId]) { + adjacencyList[depId].push(task.task_id); + inDegree[task.task_id]++; + } + } + } + } + + // 3. トポロジカルソート(Kahn's Algorithm) + const queue = []; + const topologicalOrder = []; + + for (const taskId in inDegree) { + if (inDegree[taskId] === 0) { + queue.push(taskId); + } + } + + while (queue.length > 0) { + const current = queue.shift(); + topologicalOrder.push(current); + + for (const neighbor of adjacencyList[current]) { + inDegree[neighbor]--; + if (inDegree[neighbor] === 0) { + queue.push(neighbor); + } + } + } + + // 循環依存チェック + if (topologicalOrder.length !== tasks.length) { + Logger.log('[ERROR] Circular dependency detected in critical path analysis'); + return { + criticalTasks: [], + taskMetrics: {}, + nearCriticalTasks: [], + projectDuration: 0, + error: 'circular_dependency' + }; + } + + // 4. Forward Pass(ES/EF計算) + const taskMetrics = {}; + + for (const taskId of topologicalOrder) { + const task = taskMap[taskId]; + const duration = calculateTaskDuration(task.start_date, task.end_date); + + taskMetrics[taskId] = { + es: 0, + ef: 0, + ls: 0, + lf: 0, + slack: 0, + duration: duration + }; + + // 先行タスクの最大EFを計算 + let maxEF = 0; + if (task.dependencies && task.dependencies.length > 0) { + for (const depId of task.dependencies) { + if (taskMetrics[depId]) { + maxEF = Math.max(maxEF, taskMetrics[depId].ef); + } + } + } + + taskMetrics[taskId].es = maxEF; + taskMetrics[taskId].ef = maxEF + duration; + } + + // 5. プロジェクト全体の所要時間を計算 + let projectDuration = 0; + for (const taskId in taskMetrics) { + projectDuration = Math.max(projectDuration, taskMetrics[taskId].ef); + } + + // 6. Backward Pass(LS/LF計算) + for (let i = topologicalOrder.length - 1; i >= 0; i--) { + const taskId = topologicalOrder[i]; + const task = taskMap[taskId]; + + // 後続タスクがない場合はLF = EF + if (adjacencyList[taskId].length === 0) { + taskMetrics[taskId].lf = taskMetrics[taskId].ef; + } else { + // 後続タスクの最小LSを計算 + let minLS = Infinity; + for (const successorId of adjacencyList[taskId]) { + if (taskMetrics[successorId]) { + minLS = Math.min(minLS, taskMetrics[successorId].ls); + } + } + taskMetrics[taskId].lf = minLS; + } + + taskMetrics[taskId].ls = taskMetrics[taskId].lf - taskMetrics[taskId].duration; + } + + // 7. Slack計算とクリティカルパス/Near-critical判定 + const criticalTasks = []; + const nearCriticalTasks = []; + + for (const taskId in taskMetrics) { + const slack = taskMetrics[taskId].ls - taskMetrics[taskId].es; + taskMetrics[taskId].slack = slack; + + if (slack === 0) { + criticalTasks.push(taskId); + } else if (slack > 0 && slack <= 2) { + nearCriticalTasks.push(taskId); + } + } + + return { + criticalTasks: criticalTasks, + taskMetrics: taskMetrics, + nearCriticalTasks: nearCriticalTasks, + projectDuration: projectDuration + }; +} + +/** + * 開始日と終了日から所要日数を計算 + * + * @param {string} startDate - 開始日 (YYYY-MM-DD) + * @param {string} endDate - 終了日 (YYYY-MM-DD) + * @return {number} 所要日数 + */ +function calculateTaskDuration(startDate, endDate) { + if (!startDate || !endDate) return 1; // デフォルト1日 + + const start = new Date(startDate); + const end = new Date(endDate); + const duration = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1; + + return duration > 0 ? duration : 1; +} + +// ============================================================ +// バーンダウンチャート機能 +// ============================================================ + +/** + * 日次バーンダウンデータ記録 + * + * トリガー経由で毎日自動実行され、全プロジェクトの進捗状況を記録します。 + * 同日のデータが既に存在する場合は上書きします。 + */ +function recordDailyBurndownData() { + try { + const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID); + const sheetManager = new SheetManager(); + const burndownSheet = sheetManager.getOrCreateBurndownSheet(); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const dateStr = Utilities.formatDate(today, 'Asia/Tokyo', 'yyyy-MM-dd'); + + // 全Ganttシートを取得(プロジェクト識別子で判定) + const ganttSheets = ss.getSheets().filter(sheet => + sheet.getName().match(/^\d{1,4}_.+$/) + ); + + if (ganttSheets.length === 0) { + Logger.log('[INFO] No Gantt sheets found for burndown recording'); + return; + } + + Logger.log('[INFO] Recording burndown data for ' + ganttSheets.length + ' projects'); + + for (const ganttSheet of ganttSheets) { + try { + const projectData = sheetManager.readProjectDataFromSheet(ganttSheet); + const projectName = projectData.project_name || ganttSheet.getName(); + + // 進捗率計算 + const expectedProgress = calculateExpectedProgress(projectData); + const completedTasks = projectData.tasks.filter(t => t.progress === 100).length; + const totalTasks = projectData.tasks.length; + const actualProgress = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0; + const remainingTasks = totalTasks - completedTasks; + + // ベロシティ計算(過去7日間) + const velocity = calculateVelocity(burndownSheet, projectName, 7); + + // 完了予測日計算 + let predictedDate = '-'; + if (velocity > 0 && remainingTasks > 0) { + const daysToCompletion = Math.ceil(remainingTasks / velocity); + const predicted = new Date(today); + predicted.setDate(predicted.getDate() + daysToCompletion); + predictedDate = Utilities.formatDate(predicted, 'Asia/Tokyo', 'yyyy-MM-dd'); + } + + // 既存行を検索(同日・同プロジェクト) + const existingRow = findBurndownRow(burndownSheet, today, projectName); + + const rowData = [ + dateStr, + projectName, + expectedProgress, + actualProgress, + remainingTasks, + completedTasks, + totalTasks, + velocity, + predictedDate + ]; + + if (existingRow > 0) { + // 既存データを上書き + burndownSheet.getRange(existingRow, 1, 1, rowData.length).setValues([rowData]); + Logger.log('[INFO] Updated burndown data for ' + projectName); + } else { + // 新規データを追加 + burndownSheet.appendRow(rowData); + Logger.log('[INFO] Recorded new burndown data for ' + projectName); + } + + } catch (projectError) { + Logger.log('[ERROR] Failed to record burndown for ' + ganttSheet.getName() + ': ' + projectError.message); + } + } + + // 古いデータをクリーンアップ(90日超過) + cleanupOldBurndownData(burndownSheet, 90); + + Logger.log('[INFO] Daily burndown recording completed'); + + } catch (error) { + Logger.log('[ERROR] recordDailyBurndownData failed: ' + error.message); + } +} + +/** + * 予定進捗率の計算 + * + * 現在日時を基準に、プロジェクト期間における理想的な進捗率を計算します。 + * 計算式: (経過日数 / 全体日数) × 100 + * + * @param {Object} projectData - プロジェクトデータ + * @return {Number} 予定進捗率(%、小数第1位まで) + */ +function calculateExpectedProgress(projectData) { + try { + const tasks = projectData.tasks || []; + if (tasks.length === 0) return 0; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // プロジェクト全体の開始日・終了日を取得 + let projectStart = null; + let projectEnd = null; + + for (const task of tasks) { + if (task.start_date) { + if (!projectStart || task.start_date < projectStart) { + projectStart = task.start_date; + } + } + if (task.end_date) { + if (!projectEnd || task.end_date > projectEnd) { + projectEnd = task.end_date; + } + } + } + + if (!projectStart || !projectEnd) { + Logger.log('[WARNING] Cannot calculate expected progress: missing start/end dates'); + return 0; + } + + // 日数計算 + const totalDays = Math.max(1, Math.ceil((projectEnd - projectStart) / (1000 * 60 * 60 * 24))); + const elapsedDays = Math.max(0, Math.ceil((today - projectStart) / (1000 * 60 * 60 * 24))); + + // 予定進捗率を計算(0~100%にクリップ) + const expectedProgress = Math.min(100, Math.max(0, (elapsedDays / totalDays) * 100)); + + return Math.round(expectedProgress * 10) / 10; // 小数第1位まで + + } catch (error) { + Logger.log('[ERROR] calculateExpectedProgress failed: ' + error.message); + return 0; + } +} + +/** + * ベロシティ計算(過去N日間の平均完了タスク数/日) + * + * @param {Sheet} burndownSheet - バーンダウンデータシート + * @param {String} projectName - プロジェクト名 + * @param {Number} days - 過去何日間のデータを使用するか(デフォルト: 7) + * @return {Number} ベロシティ(小数第2位まで) + */ +function calculateVelocity(burndownSheet, projectName, days) { + try { + days = days || 7; + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const dataRange = burndownSheet.getDataRange(); + const values = dataRange.getValues(); + + // ヘッダー行をスキップし、該当プロジェクトのデータを抽出 + const projectData = []; + for (let i = 1; i < values.length; i++) { + const row = values[i]; + const recordDateStr = row[0]; // A列: 記録日 + const recordProjectName = row[1]; // B列: プロジェクト名 + const completedTasks = row[5]; // F列: 完了タスク数 + + if (recordProjectName !== projectName) continue; + + // 日付解析 + let recordDate; + if (recordDateStr instanceof Date) { + recordDate = new Date(recordDateStr); + } else { + recordDate = new Date(recordDateStr); + } + recordDate.setHours(0, 0, 0, 0); + + // 過去N日間のデータのみ + const daysDiff = Math.ceil((today - recordDate) / (1000 * 60 * 60 * 24)); + if (daysDiff >= 0 && daysDiff < days) { + projectData.push({ + date: recordDate, + completedTasks: completedTasks + }); + } + } + + if (projectData.length < 2) { + // データ不足の場合はベロシティ計算不可 + return 0; + } + + // 日付でソート(古い順) + projectData.sort((a, b) => a.date - b.date); + + // 完了タスク数の差分を計算 + const oldest = projectData[0].completedTasks; + const newest = projectData[projectData.length - 1].completedTasks; + const taskDiff = newest - oldest; + const dayDiff = Math.max(1, projectData.length - 1); + + const velocity = taskDiff / dayDiff; + + return Math.round(velocity * 100) / 100; // 小数第2位まで + + } catch (error) { + Logger.log('[ERROR] calculateVelocity failed: ' + error.message); + return 0; + } +} + +/** + * バーンダウンシートから指定日・プロジェクトの行番号を検索 + * + * @param {Sheet} burndownSheet - バーンダウンデータシート + * @param {Date} date - 記録日 + * @param {String} projectName - プロジェクト名 + * @return {Number} 行番号(見つからない場合は0) + */ +function findBurndownRow(burndownSheet, date, projectName) { + try { + const dateStr = Utilities.formatDate(date, 'Asia/Tokyo', 'yyyy-MM-dd'); + const dataRange = burndownSheet.getDataRange(); + const values = dataRange.getValues(); + + for (let i = 1; i < values.length; i++) { // ヘッダー行をスキップ + const row = values[i]; + const recordDateStr = row[0]; // A列: 記録日 + const recordProjectName = row[1]; // B列: プロジェクト名 + + // 日付文字列に変換して比較 + let compareDateStr; + if (recordDateStr instanceof Date) { + compareDateStr = Utilities.formatDate(recordDateStr, 'Asia/Tokyo', 'yyyy-MM-dd'); + } else { + compareDateStr = recordDateStr; + } + + if (compareDateStr === dateStr && recordProjectName === projectName) { + return i + 1; // 行番号(1-indexed) + } + } + + return 0; // 見つからない + + } catch (error) { + Logger.log('[ERROR] findBurndownRow failed: ' + error.message); + return 0; + } +} + +/** + * 古いバーンダウンデータの削除(指定日数より前のデータ) + * + * @param {Sheet} burndownSheet - バーンダウンデータシート + * @param {Number} days - 保持日数(デフォルト: 90日) + */ +function cleanupOldBurndownData(burndownSheet, days) { + try { + days = days || 90; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const cutoffDate = new Date(today); + cutoffDate.setDate(cutoffDate.getDate() - days); + + const dataRange = burndownSheet.getDataRange(); + const values = dataRange.getValues(); + + // 削除対象行を収集(後ろから削除するため逆順) + const rowsToDelete = []; + for (let i = 1; i < values.length; i++) { // ヘッダー行をスキップ + const row = values[i]; + const recordDateStr = row[0]; // A列: 記録日 + + let recordDate; + if (recordDateStr instanceof Date) { + recordDate = new Date(recordDateStr); + } else { + recordDate = new Date(recordDateStr); + } + recordDate.setHours(0, 0, 0, 0); + + if (recordDate < cutoffDate) { + rowsToDelete.push(i + 1); // 行番号(1-indexed) + } + } + + // 後ろから順に削除(行番号がずれないように) + rowsToDelete.reverse(); + for (const rowNum of rowsToDelete) { + burndownSheet.deleteRow(rowNum); + } + + if (rowsToDelete.length > 0) { + Logger.log('[INFO] Cleaned up ' + rowsToDelete.length + ' old burndown records (older than ' + days + ' days)'); + } + + } catch (error) { + Logger.log('[ERROR] cleanupOldBurndownData failed: ' + error.message); + } +} + +/** + * バーンダウンチャート日次記録トリガーのセットアップ + * + * この関数を実行すると、毎日午前9時に recordDailyBurndownData() を自動実行する + * トリガーが登録されます。既存のトリガーがある場合は重複登録されません。 + * + * GASエディタから手動で1回実行してください。 + */ +function setupDailyBurndownTrigger() { + try { + const functionName = 'recordDailyBurndownData'; + + // 既存のトリガーを確認 + const triggers = ScriptApp.getProjectTriggers(); + const existingTrigger = triggers.find(trigger => + trigger.getHandlerFunction() === functionName + ); + + if (existingTrigger) { + Logger.log('[INFO] Daily burndown trigger already exists'); + Logger.log('✅ トリガーは既に設定されています(毎日午前9時実行)'); + return; + } + + // 新規トリガー作成(毎日午前9時) + ScriptApp.newTrigger(functionName) + .timeBased() + .atHour(9) + .everyDays(1) + .create(); + + Logger.log('[INFO] Daily burndown trigger created successfully'); + Logger.log('✅ トリガーを作成しました!'); + Logger.log('📅 毎日午前9時にバーンダウンデータが自動記録されます'); + + } catch (error) { + Logger.log('[ERROR] Failed to setup trigger: ' + error.message); + Logger.log('❌ トリガー設定に失敗しました: ' + error.message); + } +} + +/** + * バーンダウンチャート日次記録トリガーの削除 + * + * 自動記録を停止したい場合にこの関数を実行してください。 + */ +function removeDailyBurndownTrigger() { + try { + const functionName = 'recordDailyBurndownData'; + const triggers = ScriptApp.getProjectTriggers(); + + let removedCount = 0; + for (const trigger of triggers) { + if (trigger.getHandlerFunction() === functionName) { + ScriptApp.deleteTrigger(trigger); + removedCount++; + } + } + + if (removedCount > 0) { + Logger.log('[INFO] Removed ' + removedCount + ' trigger(s)'); + Logger.log('✅ トリガーを削除しました(' + removedCount + '件)'); + Logger.log('⏸️ バーンダウンデータの自動記録が停止されました'); + } else { + Logger.log('[INFO] No triggers found'); + Logger.log('ℹ️ 削除対象のトリガーはありませんでした'); + } + + } catch (error) { + Logger.log('[ERROR] Failed to remove trigger: ' + error.message); + Logger.log('❌ トリガー削除に失敗しました: ' + error.message); + } +} + +/** + * 全シート同期トリガーのセットアップ + * + * この関数を実行すると、毎日午前10時に syncAllSheets() を自動実行する + * トリガーが登録されます。既存のトリガーがある場合は重複登録されません。 + * + * GASエディタから手動で1回実行してください。 + */ +function setupSyncAllSheetsTrigger() { + try { + const functionName = 'syncAllSheets'; + + // 既存のトリガーを確認 + const triggers = ScriptApp.getProjectTriggers(); + const existingTrigger = triggers.find(trigger => + trigger.getHandlerFunction() === functionName + ); + + if (existingTrigger) { + Logger.log('[INFO] Sync all sheets trigger already exists'); + Logger.log('✅ トリガーは既に設定されています(毎日午前10時実行)'); + return; + } + + // 新規トリガー作成(毎日午前10時) + ScriptApp.newTrigger(functionName) + .timeBased() + .atHour(10) + .everyDays(1) + .create(); + + Logger.log('[INFO] Sync all sheets trigger created successfully'); + Logger.log('✅ トリガーを作成しました!'); + Logger.log('📅 毎日午前10時に全シート同期が自動実行されます'); + Logger.log('📊 プロジェクト一覧、全タスク、期日切れタスクが更新されます'); + + } catch (error) { + Logger.log('[ERROR] Failed to setup trigger: ' + error.message); + Logger.log('❌ トリガー設定に失敗しました: ' + error.message); + } +} + +/** + * 全シート同期トリガーの削除 + * + * 自動同期を停止したい場合にこの関数を実行してください。 + */ +function removeSyncAllSheetsTrigger() { + try { + const functionName = 'syncAllSheets'; + const triggers = ScriptApp.getProjectTriggers(); + + let removedCount = 0; + for (const trigger of triggers) { + if (trigger.getHandlerFunction() === functionName) { + ScriptApp.deleteTrigger(trigger); + removedCount++; + } + } + + if (removedCount > 0) { + Logger.log('[INFO] Removed ' + removedCount + ' trigger(s)'); + Logger.log('✅ トリガーを削除しました(' + removedCount + '件)'); + Logger.log('⏸️ 全シート同期の自動実行が停止されました'); + } else { + Logger.log('[INFO] No triggers found'); + Logger.log('ℹ️ 削除対象のトリガーはありませんでした'); + } + + } catch (error) { + Logger.log('[ERROR] Failed to remove trigger: ' + error.message); + Logger.log('❌ トリガー削除に失敗しました: ' + error.message); + } +} + +/** + * プロジェクト健全性レポート生成 + * + * @param {Object} projectData - プロジェクトデータ + * @param {Date} today - 基準日 + * @return {Object} レポートオブジェクト + */ +function generateProjectHealthReport(projectData, today) { + try { + today = today || new Date(); + today.setHours(0, 0, 0, 0); + + const tasks = projectData.tasks || []; + const projectName = projectData.project_name || 'プロジェクト'; + + // 基本統計 + const totalTasks = tasks.length; + let completedTasks = 0; + let overdueTasks = 0; + let totalProgress = 0; + + // 担当者別ワークロード + const assigneeWorkload = {}; + + // 今週期限、来週開始のタスク + const thisWeekDueTasks = []; + const nextWeekStartTasks = []; + + // 今週の範囲(日曜日~土曜日) + const thisWeekEnd = new Date(today); + thisWeekEnd.setDate(today.getDate() + (6 - today.getDay())); // 次の土曜日 + thisWeekEnd.setHours(23, 59, 59, 999); + + // 来週の範囲 + const nextWeekStart = new Date(thisWeekEnd); + nextWeekStart.setDate(thisWeekEnd.getDate() + 1); + nextWeekStart.setHours(0, 0, 0, 0); + + const nextWeekEnd = new Date(nextWeekStart); + nextWeekEnd.setDate(nextWeekStart.getDate() + 6); + nextWeekEnd.setHours(23, 59, 59, 999); + + // プロジェクト全体の開始日・終了日を取得 + let projectStartDate = null; + let projectEndDate = null; + + for (const task of tasks) { + // 進捗率の正規化 + const progress = task.progress || 0; + const progressPercent = progress <= 1 ? progress * 100 : progress; + totalProgress += progressPercent; + + if (progressPercent >= 100) { + completedTasks++; + } + + // 期日切れチェック + if (task.end_date) { + const endDate = new Date(task.end_date); + endDate.setHours(0, 0, 0, 0); + + if (endDate < today && progressPercent < 100) { + overdueTasks++; + } + + // 今週期限のタスク + if (endDate >= today && endDate <= thisWeekEnd && progressPercent < 100) { + thisWeekDueTasks.push(task); + } + + // プロジェクト終了日の更新 + if (!projectEndDate || endDate > projectEndDate) { + projectEndDate = endDate; + } + } + + // 開始日チェック + if (task.start_date) { + const startDate = new Date(task.start_date); + startDate.setHours(0, 0, 0, 0); + + // 来週開始のタスク + if (startDate >= nextWeekStart && startDate <= nextWeekEnd) { + nextWeekStartTasks.push(task); + } + + // プロジェクト開始日の更新 + if (!projectStartDate || startDate < projectStartDate) { + projectStartDate = startDate; + } + } + + // 担当者別ワークロード + const assignee = task.assignee || '未割り当て'; + if (!assigneeWorkload[assignee]) { + assigneeWorkload[assignee] = { + totalTasks: 0, + completedTasks: 0, + inProgressTasks: 0, + overdueTasks: 0 + }; + } + + assigneeWorkload[assignee].totalTasks++; + + if (progressPercent >= 100) { + assigneeWorkload[assignee].completedTasks++; + } else if (progressPercent > 0) { + assigneeWorkload[assignee].inProgressTasks++; + } + + if (task.end_date) { + const endDate = new Date(task.end_date); + endDate.setHours(0, 0, 0, 0); + if (endDate < today && progressPercent < 100) { + assigneeWorkload[assignee].overdueTasks++; + } + } + } + + // 平均進捗率 + const avgProgress = totalTasks > 0 ? totalProgress / totalTasks : 0; + + // 予定進捗率の計算 + let expectedProgress = 0; + let riskScore = 0; + let riskLevel = '🟢低'; + + if (projectStartDate && projectEndDate) { + const totalDuration = projectEndDate - projectStartDate; + const elapsedDuration = today - projectStartDate; + + if (totalDuration > 0 && elapsedDuration >= 0) { + expectedProgress = (elapsedDuration / totalDuration) * 100; + expectedProgress = Math.min(100, Math.max(0, expectedProgress)); // 0-100%に制限 + + // リスクスコア計算 + riskScore = expectedProgress - avgProgress; + + // リスクレベル判定 + if (riskScore > 20) { + riskLevel = '🔴高'; + } else if (riskScore > 10) { + riskLevel = '🟡中'; + } else { + riskLevel = '🟢低'; + } + } + } + + return { + projectName: projectName, + totalTasks: totalTasks, + completedTasks: completedTasks, + overdueTasks: overdueTasks, + avgProgress: Math.round(avgProgress * 10) / 10, // 小数点1桁 + expectedProgress: Math.round(expectedProgress * 10) / 10, + riskScore: Math.round(riskScore * 10) / 10, + riskLevel: riskLevel, + assigneeWorkload: assigneeWorkload, + thisWeekDueTasks: thisWeekDueTasks, + nextWeekStartTasks: nextWeekStartTasks + }; + } catch (error) { + Logger.log(`✗ プロジェクト健全性レポート生成エラー: ${error.message}`); + throw error; + } +} + +/** + * 週次プロジェクトレポート送信 + */ +function sendWeeklyReport() { + try { + Logger.log('=== 週次レポート生成開始 ==='); + + const today = new Date(); + const sheetManager = new SheetManager(); + const ss = sheetManager.ss; + const allSheets = ss.getSheets(); + + const ganttSheets = allSheets.filter(sheet => { + const name = sheet.getName(); + return name.match(/^\d{1,4}_.+$/); + }); + + if (ganttSheets.length === 0) { + Logger.log('⚠ Ganttシートが見つかりませんでした。'); + return; + } + + const reports = []; + let totalProjects = 0; + let totalTasks = 0; + let totalCompleted = 0; + let totalOverdue = 0; + let highRiskProjects = 0; + let mediumRiskProjects = 0; + let lowRiskProjects = 0; + + // 各プロジェクトのレポート生成 + for (const ganttSheet of ganttSheets) { + try { + const projectData = sheetManager.readProjectDataFromSheet(ganttSheet); + if (!projectData) continue; + + const report = generateProjectHealthReport(projectData, today); + reports.push(report); + + totalProjects++; + totalTasks += report.totalTasks; + totalCompleted += report.completedTasks; + totalOverdue += report.overdueTasks; + + if (report.riskLevel === '🔴高') { + highRiskProjects++; + } else if (report.riskLevel === '🟡中') { + mediumRiskProjects++; + } else { + lowRiskProjects++; + } + } catch (error) { + Logger.log(`✗ レポート生成エラー (${ganttSheet.getName()}): ${error.message}`); + } + } + + // 週次サマリー + const overallProgress = totalTasks > 0 + ? Math.round((totalCompleted / totalTasks) * 100) + : 0; + + let summaryText = `**📊 週次プロジェクトレポート**\n\n`; + summaryText += `**全体サマリー**\n`; + summaryText += `• プロジェクト数: ${totalProjects}件\n`; + summaryText += `• 全タスク数: ${totalTasks}件\n`; + summaryText += `• 完了タスク: ${totalCompleted}件 (${overallProgress}%)\n`; + summaryText += `• 期日切れ: ${totalOverdue}件\n`; + summaryText += `• リスク状況: 🔴${highRiskProjects}件 / 🟡${mediumRiskProjects}件 / 🟢${lowRiskProjects}件\n\n`; + + // プロジェクト別詳細(リスク高い順) + const sortedReports = reports.sort((a, b) => { + const riskOrder = { '🔴高': 3, '🟡中': 2, '🟢低': 1 }; + return riskOrder[b.riskLevel] - riskOrder[a.riskLevel]; + }); + + summaryText += `**プロジェクト別状況**\n`; + for (const report of sortedReports.slice(0, 10)) { + const progressPercent = report.totalTasks > 0 + ? Math.round((report.completedTasks / report.totalTasks) * 100) + : 0; + + summaryText += `\n${report.riskLevel} **${report.projectName}**\n`; + summaryText += ` 進捗: ${progressPercent}% (${report.completedTasks}/${report.totalTasks}件)`; + + if (report.overdueTasks > 0) { + summaryText += ` | 期日切れ: ${report.overdueTasks}件`; + } + + if (report.thisWeekDueTasks.length > 0) { + summaryText += ` | 今週期限: ${report.thisWeekDueTasks.length}件`; + } + + summaryText += `\n`; + } + + if (sortedReports.length > 10) { + summaryText += `\n... 他${sortedReports.length - 10}件のプロジェクト\n`; + } + + // 今週期限・来週開始のタスク集計 + const allThisWeekDueTasks = []; + const allNextWeekStartTasks = []; + + for (const report of reports) { + for (const task of report.thisWeekDueTasks) { + allThisWeekDueTasks.push({ + projectName: report.projectName, + ...task + }); + } + + for (const task of report.nextWeekStartTasks) { + allNextWeekStartTasks.push({ + projectName: report.projectName, + ...task + }); + } + } + + // 今週期限タスク + if (allThisWeekDueTasks.length > 0) { + summaryText += `\n**📅 今週完了予定タスク (${allThisWeekDueTasks.length}件)**\n`; + + for (const task of allThisWeekDueTasks.slice(0, 10)) { + const taskName = task.task_name || task.task_id; + const endDate = new Date(task.end_date); + const dateStr = `${endDate.getMonth() + 1}/${endDate.getDate()}`; + + summaryText += `• ${taskName} (${task.projectName}) - ${dateStr}\n`; + } + + if (allThisWeekDueTasks.length > 10) { + summaryText += `... 他${allThisWeekDueTasks.length - 10}件\n`; + } + } + + // 来週開始タスク + if (allNextWeekStartTasks.length > 0) { + summaryText += `\n**🚀 来週開始予定タスク (${allNextWeekStartTasks.length}件)**\n`; + + for (const task of allNextWeekStartTasks.slice(0, 10)) { + const taskName = task.task_name || task.task_id; + const startDate = new Date(task.start_date); + const dateStr = `${startDate.getMonth() + 1}/${startDate.getDate()}`; + + summaryText += `• ${taskName} (${task.projectName}) - ${dateStr}\n`; + } + + if (allNextWeekStartTasks.length > 10) { + summaryText += `... 他${allNextWeekStartTasks.length - 10}件\n`; + } + } + + // Discord通知送信 + const payload = { + embeds: [{ + title: '📊 週次プロジェクトレポート', + description: summaryText, + color: highRiskProjects > 0 ? 0xFF6B6B : (mediumRiskProjects > 0 ? 0xFFA726 : 0x66BB6A), + footer: { text: `生成日時: ${today.toLocaleDateString('ja-JP')}` }, + timestamp: new Date().toISOString() + }] + }; + + const options = { + method: 'post', + contentType: 'application/json', + payload: JSON.stringify(payload), + muteHttpExceptions: true + }; + + const response = UrlFetchApp.fetch(CONFIG.DISCORD_WEBHOOK_URL, options); + Logger.log(`✓ 週次レポート送信完了: ${response.getResponseCode()}`); + Logger.log(`✓ ${totalProjects}件のプロジェクトをレポート`); + + } catch (error) { + Logger.log('✗ 週次レポート生成エラー: ' + error.message); + sendDiscordNotification( + `週次レポート生成中にエラーが発生しました。\n\nエラー内容: ${error.message}`, + true + ); + throw error; + } +} + +/** + * 月次プロジェクトレポート送信 + */ +function sendMonthlyReport() { + try { + Logger.log('=== 月次レポート生成開始 ==='); + + const today = new Date(); + const sheetManager = new SheetManager(); + const ss = sheetManager.ss; + const allSheets = ss.getSheets(); + + const ganttSheets = allSheets.filter(sheet => { + const name = sheet.getName(); + return name.match(/^\d{1,4}_.+$/); + }); + + if (ganttSheets.length === 0) { + Logger.log('⚠ Ganttシートが見つかりませんでした。'); + return; + } + + const reports = []; + let totalProjects = 0; + let totalTasks = 0; + let totalCompleted = 0; + let totalOverdue = 0; + let highRiskProjects = 0; + let mediumRiskProjects = 0; + let lowRiskProjects = 0; + + // 先月・今月の範囲を計算 + const thisMonthStart = new Date(today.getFullYear(), today.getMonth(), 1); + thisMonthStart.setHours(0, 0, 0, 0); + + const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0); + thisMonthEnd.setHours(23, 59, 59, 999); + + const lastMonthStart = new Date(today.getFullYear(), today.getMonth() - 1, 1); + lastMonthStart.setHours(0, 0, 0, 0); + + const lastMonthEnd = new Date(today.getFullYear(), today.getMonth(), 0); + lastMonthEnd.setHours(23, 59, 59, 999); + + let lastMonthCompletedCount = 0; + let thisMonthTargetCount = 0; + + // 各プロジェクトのレポート生成 + for (const ganttSheet of ganttSheets) { + try { + const projectData = sheetManager.readProjectDataFromSheet(ganttSheet); + if (!projectData) continue; + + const report = generateProjectHealthReport(projectData, today); + reports.push(report); + + totalProjects++; + totalTasks += report.totalTasks; + totalCompleted += report.completedTasks; + totalOverdue += report.overdueTasks; + + if (report.riskLevel === '🔴高') { + highRiskProjects++; + } else if (report.riskLevel === '🟡中') { + mediumRiskProjects++; + } else { + lowRiskProjects++; + } + + // 先月完了タスクと今月目標タスクのカウント + for (const task of projectData.tasks) { + const progress = task.progress || 0; + const progressPercent = progress <= 1 ? progress * 100 : progress; + + // 先月完了したタスク(先月中に100%になったもの) + if (progressPercent >= 100 && task.end_date) { + const endDate = new Date(task.end_date); + if (endDate >= lastMonthStart && endDate <= lastMonthEnd) { + lastMonthCompletedCount++; + } + } + + // 今月期限のタスク(未完了) + if (progressPercent < 100 && task.end_date) { + const endDate = new Date(task.end_date); + if (endDate >= thisMonthStart && endDate <= thisMonthEnd) { + thisMonthTargetCount++; + } + } + } + + } catch (error) { + Logger.log(`✗ レポート生成エラー (${ganttSheet.getName()}): ${error.message}`); + } + } + + // 月次サマリー + const overallProgress = totalTasks > 0 + ? Math.round((totalCompleted / totalTasks) * 100) + : 0; + + let summaryText = `**📊 月次プロジェクトレポート**\n\n`; + summaryText += `**全体サマリー**\n`; + summaryText += `• プロジェクト数: ${totalProjects}件\n`; + summaryText += `• 全タスク数: ${totalTasks}件\n`; + summaryText += `• 完了タスク: ${totalCompleted}件 (${overallProgress}%)\n`; + summaryText += `• 期日切れ: ${totalOverdue}件\n`; + summaryText += `• リスク状況: 🔴${highRiskProjects}件 / 🟡${mediumRiskProjects}件 / 🟢${lowRiskProjects}件\n\n`; + + // 月次固有情報 + summaryText += `**月次統計**\n`; + summaryText += `• 先月完了タスク: ${lastMonthCompletedCount}件\n`; + summaryText += `• 今月目標タスク: ${thisMonthTargetCount}件\n\n`; + + // プロジェクト別詳細(リスク高い順) + const sortedReports = reports.sort((a, b) => { + const riskOrder = { '🔴高': 3, '🟡中': 2, '🟢低': 1 }; + return riskOrder[b.riskLevel] - riskOrder[a.riskLevel]; + }); + + summaryText += `**プロジェクト別状況**\n`; + for (const report of sortedReports.slice(0, 10)) { + const progressPercent = report.totalTasks > 0 + ? Math.round((report.completedTasks / report.totalTasks) * 100) + : 0; + + summaryText += `\n${report.riskLevel} **${report.projectName}**\n`; + summaryText += ` 進捗: ${progressPercent}% (${report.completedTasks}/${report.totalTasks}件)`; + + if (report.overdueTasks > 0) { + summaryText += ` | 期日切れ: ${report.overdueTasks}件`; + } + + summaryText += `\n`; + } + + if (sortedReports.length > 10) { + summaryText += `\n... 他${sortedReports.length - 10}件のプロジェクト\n`; + } + + // 今週期限・来週開始のタスク集計 + const allThisWeekDueTasks = []; + const allNextWeekStartTasks = []; + + for (const report of reports) { + for (const task of report.thisWeekDueTasks) { + allThisWeekDueTasks.push({ + projectName: report.projectName, + ...task + }); + } + + for (const task of report.nextWeekStartTasks) { + allNextWeekStartTasks.push({ + projectName: report.projectName, + ...task + }); + } + } + + // 今週期限タスク + if (allThisWeekDueTasks.length > 0) { + summaryText += `\n**📅 今週完了予定タスク (${allThisWeekDueTasks.length}件)**\n`; + + for (const task of allThisWeekDueTasks.slice(0, 10)) { + const taskName = task.task_name || task.task_id; + const endDate = new Date(task.end_date); + const dateStr = `${endDate.getMonth() + 1}/${endDate.getDate()}`; + + summaryText += `• ${taskName} (${task.projectName}) - ${dateStr}\n`; + } + + if (allThisWeekDueTasks.length > 10) { + summaryText += `... 他${allThisWeekDueTasks.length - 10}件\n`; + } + } + + // 来週開始タスク + if (allNextWeekStartTasks.length > 0) { + summaryText += `\n**🚀 来週開始予定タスク (${allNextWeekStartTasks.length}件)**\n`; + + for (const task of allNextWeekStartTasks.slice(0, 10)) { + const taskName = task.task_name || task.task_id; + const startDate = new Date(task.start_date); + const dateStr = `${startDate.getMonth() + 1}/${startDate.getDate()}`; + + summaryText += `• ${taskName} (${task.projectName}) - ${dateStr}\n`; + } + + if (allNextWeekStartTasks.length > 10) { + summaryText += `... 他${allNextWeekStartTasks.length - 10}件\n`; + } + } + + // Discord通知送信 + const payload = { + embeds: [{ + title: '📊 月次プロジェクトレポート', + description: summaryText, + color: highRiskProjects > 0 ? 0xFF6B6B : (mediumRiskProjects > 0 ? 0xFFA726 : 0x66BB6A), + footer: { text: `生成日時: ${today.toLocaleDateString('ja-JP')}` }, + timestamp: new Date().toISOString() + }] + }; + + const options = { + method: 'post', + contentType: 'application/json', + payload: JSON.stringify(payload), + muteHttpExceptions: true + }; + + const response = UrlFetchApp.fetch(CONFIG.DISCORD_WEBHOOK_URL, options); + Logger.log(`✓ 月次レポート送信完了: ${response.getResponseCode()}`); + Logger.log(`✓ ${totalProjects}件のプロジェクトをレポート`); + + } catch (error) { + Logger.log('✗ 月次レポート生成エラー: ' + error.message); + sendDiscordNotification( + `月次レポート生成中にエラーが発生しました。\n\nエラー内容: ${error.message}`, + true + ); + throw error; + } +} + +/** + * 見積精度計算 + * + * @param {Object} projectData - プロジェクトデータ + * @return {Object} 精度分析レポート + */ +function calculateEstimationAccuracy(projectData) { + try { + const projectName = projectData.project_name || 'プロジェクト'; + const tasks = projectData.tasks || []; + + // 実績工数が記録されている完了タスクを抽出 + const completedTasksWithActual = []; + const assigneeData = {}; + + for (const task of tasks) { + const progress = task.progress || 0; + const progressPercent = progress <= 1 ? progress * 100 : progress; + + // 完了タスクかつ見積・実績の両方がある + if (progressPercent >= 100 && task.estimated_hours && task.actual_hours) { + const estimated = parseFloat(task.estimated_hours); + const actual = parseFloat(task.actual_hours); + + if (estimated > 0 && actual > 0) { + // 誤差率計算: (実績 - 見積) / 見積 × 100 + const errorRate = ((actual - estimated) / estimated) * 100; + const absoluteError = Math.abs(errorRate); + + const taskAccuracy = { + task_id: task.task_id, + task_name: task.task_name || task.task_id, + assignee: task.assignee || '未割り当て', + estimated: estimated, + actual: actual, + errorRate: Math.round(errorRate * 10) / 10, // 小数点1桁 + absoluteError: Math.round(absoluteError * 10) / 10 + }; + + completedTasksWithActual.push(taskAccuracy); + + // 担当者別データの集計 + const assignee = task.assignee || '未割り当て'; + if (!assigneeData[assignee]) { + assigneeData[assignee] = { + totalTasks: 0, + totalEstimated: 0, + totalActual: 0, + totalErrorRate: 0 + }; + } + + assigneeData[assignee].totalTasks++; + assigneeData[assignee].totalEstimated += estimated; + assigneeData[assignee].totalActual += actual; + assigneeData[assignee].totalErrorRate += errorRate; + } + } + } + + // プロジェクト全体の精度 + let overallAccuracy = 0; + let totalEstimated = 0; + let totalActual = 0; + + if (completedTasksWithActual.length > 0) { + for (const taskAcc of completedTasksWithActual) { + totalEstimated += taskAcc.estimated; + totalActual += taskAcc.actual; + } + + if (totalEstimated > 0) { + overallAccuracy = ((totalActual - totalEstimated) / totalEstimated) * 100; + overallAccuracy = Math.round(overallAccuracy * 10) / 10; + } + } + + // 担当者別精度 + const assigneeAccuracy = []; + for (const assignee in assigneeData) { + const data = assigneeData[assignee]; + const avgErrorRate = data.totalTasks > 0 + ? data.totalErrorRate / data.totalTasks + : 0; + + assigneeAccuracy.push({ + assignee: assignee, + totalTasks: data.totalTasks, + totalEstimated: Math.round(data.totalEstimated * 10) / 10, + totalActual: Math.round(data.totalActual * 10) / 10, + avgErrorRate: Math.round(avgErrorRate * 10) / 10 + }); + } + + // 誤差率順にソート + assigneeAccuracy.sort((a, b) => Math.abs(b.avgErrorRate) - Math.abs(a.avgErrorRate)); + + // 最も誤差が大きいタスク(上位5件) + const worstTasks = completedTasksWithActual + .sort((a, b) => b.absoluteError - a.absoluteError) + .slice(0, 5); + + return { + projectName: projectName, + overallAccuracy: overallAccuracy, + completedTasksWithActual: completedTasksWithActual.length, + totalEstimated: Math.round(totalEstimated * 10) / 10, + totalActual: Math.round(totalActual * 10) / 10, + assigneeAccuracy: assigneeAccuracy, + worstTasks: worstTasks + }; + } catch (error) { + Logger.log(`✗ 見積精度計算エラー: ${error.message}`); + throw error; + } +} + +/** + * 見積精度レポート送信 + */ +function sendEstimationAccuracyReport() { + try { + Logger.log('=== 見積精度レポート生成開始 ==='); + + const sheetManager = new SheetManager(); + const ss = sheetManager.ss; + const allSheets = ss.getSheets(); + + const ganttSheets = allSheets.filter(sheet => { + const name = sheet.getName(); + return name.match(/^\d{1,4}_.+$/); + }); + + if (ganttSheets.length === 0) { + Logger.log('⚠ Ganttシートが見つかりませんでした。'); + return; + } + + const accuracyReports = []; + let totalProjects = 0; + let totalTasksWithActual = 0; + let overallTotalEstimated = 0; + let overallTotalActual = 0; + + const allAssigneeData = {}; + const allWorstTasks = []; + + // 各プロジェクトの精度分析 + for (const ganttSheet of ganttSheets) { + try { + const projectData = sheetManager.readProjectDataFromSheet(ganttSheet); + if (!projectData) continue; + + const accuracy = calculateEstimationAccuracy(projectData); + + if (accuracy.completedTasksWithActual > 0) { + accuracyReports.push(accuracy); + totalProjects++; + totalTasksWithActual += accuracy.completedTasksWithActual; + overallTotalEstimated += accuracy.totalEstimated; + overallTotalActual += accuracy.totalActual; + + // 担当者別データの統合 + for (const assigneeAcc of accuracy.assigneeAccuracy) { + if (!allAssigneeData[assigneeAcc.assignee]) { + allAssigneeData[assigneeAcc.assignee] = { + totalTasks: 0, + totalEstimated: 0, + totalActual: 0 + }; + } + + allAssigneeData[assigneeAcc.assignee].totalTasks += assigneeAcc.totalTasks; + allAssigneeData[assigneeAcc.assignee].totalEstimated += assigneeAcc.totalEstimated; + allAssigneeData[assigneeAcc.assignee].totalActual += assigneeAcc.totalActual; + } + + // ワーストタスク収集 + for (const worstTask of accuracy.worstTasks) { + allWorstTasks.push({ + projectName: accuracy.projectName, + ...worstTask + }); + } + } + } catch (error) { + Logger.log(`✗ 精度計算エラー (${ganttSheet.getName()}): ${error.message}`); + } + } + + if (totalProjects === 0) { + Logger.log('⚠ 見積・実績の両方が記録されているタスクがありません。'); + sendDiscordNotification('見積精度レポート: 分析対象のタスクがありません。', false); + return; + } + + // 全体精度 + const overallAccuracy = overallTotalEstimated > 0 + ? ((overallTotalActual - overallTotalEstimated) / overallTotalEstimated) * 100 + : 0; + + // 担当者別精度の計算 + const assigneeAccuracyList = []; + for (const assignee in allAssigneeData) { + const data = allAssigneeData[assignee]; + const errorRate = data.totalEstimated > 0 + ? ((data.totalActual - data.totalEstimated) / data.totalEstimated) * 100 + : 0; + + assigneeAccuracyList.push({ + assignee: assignee, + totalTasks: data.totalTasks, + totalEstimated: Math.round(data.totalEstimated * 10) / 10, + totalActual: Math.round(data.totalActual * 10) / 10, + errorRate: Math.round(errorRate * 10) / 10 + }); + } + + // 誤差率順にソート(絶対値) + assigneeAccuracyList.sort((a, b) => Math.abs(b.errorRate) - Math.abs(a.errorRate)); + + // ワーストタスク上位10件 + const topWorstTasks = allWorstTasks + .sort((a, b) => b.absoluteError - a.absoluteError) + .slice(0, 10); + + // Discord通知作成 + let summaryText = `**📊 見積精度レポート**\n\n`; + summaryText += `**全体サマリー**\n`; + summaryText += `• 分析プロジェクト数: ${totalProjects}件\n`; + summaryText += `• 分析タスク数: ${totalTasksWithActual}件\n`; + summaryText += `• 総見積工数: ${Math.round(overallTotalEstimated * 10) / 10}h\n`; + summaryText += `• 総実績工数: ${Math.round(overallTotalActual * 10) / 10}h\n`; + summaryText += `• 全体精度: ${Math.round(overallAccuracy * 10) / 10}% ${overallAccuracy > 0 ? '(超過)' : overallAccuracy < 0 ? '(余裕)' : ''}\n\n`; + + // プロジェクト別精度(誤差大きい順) + const sortedProjects = accuracyReports.sort((a, b) => + Math.abs(b.overallAccuracy) - Math.abs(a.overallAccuracy) + ); + + summaryText += `**プロジェクト別精度**\n`; + for (const report of sortedProjects.slice(0, 10)) { + summaryText += `\n**${report.projectName}**\n`; + summaryText += ` 見積: ${report.totalEstimated}h / 実績: ${report.totalActual}h\n`; + summaryText += ` 精度: ${report.overallAccuracy}% (${report.completedTasksWithActual}タスク)\n`; + } + + if (sortedProjects.length > 10) { + summaryText += `\n... 他${sortedProjects.length - 10}件のプロジェクト\n`; + } + + // 担当者別精度 + if (assigneeAccuracyList.length > 0) { + summaryText += `\n**担当者別精度**\n`; + + for (const assigneeAcc of assigneeAccuracyList.slice(0, 10)) { + summaryText += `\n**${assigneeAcc.assignee}**\n`; + summaryText += ` 見積: ${assigneeAcc.totalEstimated}h / 実績: ${assigneeAcc.totalActual}h\n`; + summaryText += ` 精度: ${assigneeAcc.errorRate}% (${assigneeAcc.totalTasks}タスク)\n`; + } + + if (assigneeAccuracyList.length > 10) { + summaryText += `\n... 他${assigneeAccuracyList.length - 10}人の担当者\n`; + } + } + + // 見積誤差が大きいタスク + if (topWorstTasks.length > 0) { + summaryText += `\n**⚠️ 見積誤差の大きいタスク**\n`; + + for (const worstTask of topWorstTasks.slice(0, 5)) { + const sign = worstTask.errorRate > 0 ? '+' : ''; + summaryText += `\n• **${worstTask.task_name}** (${worstTask.projectName})\n`; + summaryText += ` 見積: ${worstTask.estimated}h / 実績: ${worstTask.actual}h / 誤差: ${sign}${worstTask.errorRate}%\n`; + } + + if (topWorstTasks.length > 5) { + summaryText += `\n... 他${topWorstTasks.length - 5}件のタスク\n`; + } + } + + // Discord通知送信 + const payload = { + embeds: [{ + title: '📊 見積精度レポート', + description: summaryText, + color: Math.abs(overallAccuracy) > 20 ? 0xFF6B6B : (Math.abs(overallAccuracy) > 10 ? 0xFFA726 : 0x66BB6A), + footer: { text: `分析対象: ${totalTasksWithActual}件のタスク` }, + timestamp: new Date().toISOString() + }] + }; + + const options = { + method: 'post', + contentType: 'application/json', + payload: JSON.stringify(payload), + muteHttpExceptions: true + }; + + const response = UrlFetchApp.fetch(CONFIG.DISCORD_WEBHOOK_URL, options); + Logger.log(`✓ 見積精度レポート送信完了: ${response.getResponseCode()}`); + Logger.log(`✓ ${totalProjects}件のプロジェクト、${totalTasksWithActual}件のタスクを分析`); + + } catch (error) { + Logger.log('✗ 見積精度レポート生成エラー: ' + error.message); + sendDiscordNotification( + `見積精度レポート生成中にエラーが発生しました。\n\nエラー内容: ${error.message}`, + true + ); + throw error; + } +} + +// ============================================ +// テスト関数 +// ============================================ + +/** + * 依存タスクブロッカー検知機能のテスト + * + * 実行方法: GASエディタでこの関数を選択して実行 + * 結果確認: ログビューアとDiscordチャンネルで確認 + */ +/** + * 日付フォーマット用ヘルパー関数 + * @param {Date} date - フォーマット対象の日付 + * @return {string} YYYY-MM-DD形式の文字列 + */ +function formatDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +// ============================================ +// 変更検知機能(パフォーマンス最適化) +// ============================================ + +/** + * Ganttシートの最終同期日時を取得 + * @param {Sheet} sheet - Ganttシート + * @returns {Date|null} - 最終同期日時(未同期の場合はnull) + */ +function getLastSyncTime(sheet) { + try { + const props = PropertiesService.getDocumentProperties(); + const key = `lastSync_${sheet.getName()}`; + const value = props.getProperty(key); + return value ? new Date(value) : null; + } catch (error) { + return null; + } +} + +/** + * Ganttシートの最終同期日時を記録 + * @param {Sheet} sheet - Ganttシート + */ +function setLastSyncTime(sheet) { + try { + const props = PropertiesService.getDocumentProperties(); + const key = `lastSync_${sheet.getName()}`; + const now = new Date().toISOString(); + props.setProperty(key, now); + } catch (error) { + Logger.log(`⚠ 最終同期日時の記録失敗: ${sheet.getName()} - ${error.message}`); + } +} + +/** + * Ganttシートが同期が必要かどうか判定 + * @param {Sheet} sheet - Ganttシート + * @param {number} minIntervalMinutes - 最小同期間隔(分)デフォルト5分 + * @returns {boolean} - 同期が必要ならtrue + */ +function shouldSyncSheet(sheet, minIntervalMinutes = 5) { + const lastSyncTime = getLastSyncTime(sheet); + + // 初回同期(最終同期日時が未設定) + if (!lastSyncTime) { + return true; + } + + // 現在時刻との差分を計算(分単位) + const now = new Date(); + const diffMinutes = (now - lastSyncTime) / (1000 * 60); + + // 最小間隔を経過していれば同期が必要 + return diffMinutes >= minIntervalMinutes; +} diff --git a/gas/Config.gs b/gas/Config.gs new file mode 100644 index 0000000..1dae398 --- /dev/null +++ b/gas/Config.gs @@ -0,0 +1,97 @@ +/** + * 設定管理 + */ + +const CONFIG = { + // スプレッドシートID + SPREADSHEET_ID: '1y7U-3hVfdubQPh-H39k-5bvuHF7cOkHkOuA121GtxCI', + + // Google DriveフォルダID(JSONファイルアップロード先) + // .envのGOOGLE_DRIVE_FOLDER_IDと同じ値を設定してください + DRIVE_FOLDER_ID: '1UEOEdzxO6yLlwgDS7NF4EcdicHTTAgTT', + + // Discord Webhook URL + DISCORD_WEBHOOK_URL: 'https://discord.com/api/webhooks/1450654318612185221/0wfVPnUJJ8uvfD_Y4nZ901o9ezqx2H2TGDwN27s9-Yge4nOAQ4gr96UBAlH7PyMCAhcS', + + // Todoist API Token + TODOIST_API_KEY: 'dcbc9cfc8aa52fce62a50a09e3ccecd0200faf15', + + // シート名 + SHEET_NAMES: { + PROJECT_LIST: 'プロジェクト一覧', + ALL_TASKS: '全プロジェクトタスク', + OVERDUE_TASKS: '期日切れタスク', + TODOIST_TASKS: 'Todoistタスク', + GANTT_PREFIX: 'Gantt_' // プロジェクトIDが後ろに付く + }, + + // タグごとの色設定 + TAG_COLORS: { + '企画・計画': '#4A90E2', + '設計': '#66BB6A', + '開発・実装': '#FDD835', + 'テスト・検証': '#FF9800', + 'リリース・デプロイ': '#E53935', + '運用・保守': '#AB47BC', + 'マーケティング': '#26C6DA', + '営業・商談': '#26C6DA', + '事務・管理': '#78909C', + 'その他': '#BDBDBD', + 'デフォルト': '#999999' + }, + + // バーンダウンチャートの色設定 + BURNDOWN_COLORS: { + EXPECTED_LINE: '#4A90E2', // 予定進捗ライン(青) + ACTUAL_LINE: '#66BB6A', // 実績進捗ライン(緑) + WARNING_LINE: '#FF9800', // 警告ライン(オレンジ) + DANGER_ZONE: '#FFCDD2', // 遅延ゾーン背景(薄い赤) + SAFE_ZONE: '#C8E6C9' // 順調ゾーン背景(薄い緑) + }, + + // 優先度の色設定 + PRIORITY_COLORS: { + '高': '#FF6B6B', + '中': '#FFA726', + '低': '#66BB6A' + }, + + // Ganttチャートの設定 + GANTT: { + START_COLUMN: 13, // M列から開始(A=1, M=13) - L列に実績工数を追加 + HEADER_ROW: 1, + DATA_START_ROW: 2, + DAYS_TO_SHOW: 180, // デフォルト表示日数 + CELL_WIDTH: 30 // セルの幅(ピクセル) + } +}; + +/** + * Discord通知を送信 + */ +function sendDiscordNotification(message, isError = false) { + const payload = { + embeds: [{ + title: isError ? '❌ GAS実行エラー' : '✅ GAS実行完了', + description: message, + color: isError ? 0xFF0000 : 0x00FF00, + timestamp: new Date().toISOString(), + footer: { + text: 'Gantt Chart Generator (GAS)' + } + }] + }; + + const options = { + method: 'post', + contentType: 'application/json', + payload: JSON.stringify(payload), + muteHttpExceptions: true + }; + + try { + UrlFetchApp.fetch(CONFIG.DISCORD_WEBHOOK_URL, options); + } catch (error) { + Logger.log('Discord通知エラー: ' + error); + } +} diff --git a/gas/GanttRenderer.gs b/gas/GanttRenderer.gs new file mode 100644 index 0000000..fa927c3 --- /dev/null +++ b/gas/GanttRenderer.gs @@ -0,0 +1,758 @@ +/** + * Ganttチャート描画クラス + */ + +class GanttRenderer { + constructor(sheet, projectData) { + this.sheet = sheet; + this.projectData = projectData; + this.tasks = projectData.tasks; + } + + /** + * Ganttチャート全体を描画 + */ + render() { + // プロジェクト期間を計算 + const dateRange = this._calculateDateRange(); + + // 日付ヘッダーを描画(1行目) + this._renderDateHeaders(dateRange); + + // タスク情報列のヘッダーを描画(2行目) + this._renderTaskHeaders(); + + // タスク情報を描画(4行目から) + this._renderTaskInfo(); + + // 入力規則とスタイルを設定(タスク行作成後に適用) + this._applyStyles(); + + // 行の破線を設定(タイムライン含む全列) + this._applyRowBorders(dateRange); + + // Ganttバーを描画(実線枠線で破線を上書き) + this._renderGanttBars(dateRange); + + // 不要な行を削除 + this._cleanupUnusedRows(); + } + + /** + * プロジェクト期間を計算 + */ + _calculateDateRange() { + const dates = this.tasks + .filter(task => task.start_date && task.end_date) + .flatMap(task => [new Date(task.start_date), new Date(task.end_date)]); + + if (dates.length === 0) { + const today = new Date(); + return { + start: today, + end: new Date(today.getTime() + 90 * 24 * 60 * 60 * 1000) // 90日後 + }; + } + + const minDate = new Date(Math.min(...dates)); + const maxDate = new Date(Math.max(...dates)); + + // 前後に余裕を持たせる + minDate.setDate(minDate.getDate() - 7); + maxDate.setDate(maxDate.getDate() + 7); + + return { start: minDate, end: maxDate }; + } + + /** + * 日付ヘッダーを描画(月と日) + */ + _renderDateHeaders(dateRange) { + const startCol = CONFIG.GANTT.START_COLUMN; + + // 日付を正規化(時刻部分を除去)して日数を計算 + const startDate = new Date(dateRange.start); + startDate.setHours(0, 0, 0, 0); + const endDate = new Date(dateRange.end); + endDate.setHours(0, 0, 0, 0); + + const daysDiff = Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1; + + Logger.log(`日付範囲: ${dateRange.start} 〜 ${dateRange.end}`); + Logger.log(`正規化後: ${startDate} 〜 ${endDate}`); + Logger.log(`日数: ${daysDiff}日`); + + // Google Sheetsの列数制限チェック(18,278列) + if (startCol + daysDiff > 18278) { + throw new Error(`列数が上限を超えています: ${startCol + daysDiff} > 18278`); + } + + const monthRow = []; + const dayRow = []; + let currentDate = new Date(startDate); // 正規化済みの開始日を使用 + const monthRanges = []; // 結合する範囲を記録 + + let currentMonth = null; + let monthStartCol = 0; + + // 月と日を分けて2行に(月は各月の最初のセルのみ表示、それ以降は空白) + for (let i = 0; i < daysDiff; i++) { + const month = currentDate.getMonth() + 1; + const day = currentDate.getDate(); + + // 月が変わったときのみ月を表示、それ以外は空白 + if (month !== currentMonth) { + monthRow.push(month + '月'); + if (currentMonth !== null) { + monthRanges.push({ start: monthStartCol, length: i - monthStartCol }); + } + currentMonth = month; + monthStartCol = i; + } else { + monthRow.push(''); // 空白 + } + + dayRow.push(day); + currentDate.setDate(currentDate.getDate() + 1); + } + + // 最後の月の結合範囲を記録 + monthRanges.push({ start: monthStartCol, length: daysDiff - monthStartCol }); + + Logger.log(`日付行配列サイズ: 月=${monthRow.length}, 日=${dayRow.length}`); + + // ヘッダー設定(1行目:月、2行目:日) + this.sheet.getRange(1, startCol, 1, daysDiff).setValues([monthRow]); + this.sheet.getRange(2, startCol, 1, daysDiff).setValues([dayRow]); + + // 2色交互の色付け(セル結合は行わない) + monthRanges.forEach((range, index) => { + const rangeObj = this.sheet.getRange(1, startCol + range.start, 1, range.length); + + // 2色交互に色付け(#E8F4FD と #D6EAF8) + const bgColor = index % 2 === 0 ? '#E8F4FD' : '#D6EAF8'; + rangeObj.setBackground(bgColor); + rangeObj.setHorizontalAlignment('center'); + rangeObj.setVerticalAlignment('middle'); + + // 日の行も同じ色で塗る + this.sheet.getRange(2, startCol + range.start, 1, range.length) + .setBackground(bgColor) + .setHorizontalAlignment('center') + .setVerticalAlignment('middle'); + }); + + // 列幅を調整 + for (let i = 0; i < daysDiff; i++) { + this.sheet.setColumnWidth(startCol + i, CONFIG.GANTT.CELL_WIDTH); + } + + // 固定行を設定(月1行 + 日1行 = 2行) + this.sheet.setFrozenRows(2); + } + + /** + * タスク情報列のヘッダーを描画(2行目) + */ + _renderTaskHeaders() { + const headers = [ + 'ID', + '親タスク名', + '子タスク名', + 'タグ', + 'ステータス', + '開始日', + '終了日', + '進捗率', + '担当者', + '優先度', + '工数見積(h)', + '実績工数(h)' + ]; + + const headerRange = this.sheet.getRange(2, 1, 1, headers.length); + headerRange.setValues([headers]); + headerRange.setBackground('#E8F4FD'); + headerRange.setHorizontalAlignment('center'); + headerRange.setVerticalAlignment('middle'); + + // 1行目のA~L列にも同じ青色を適用 + const firstRowRange = this.sheet.getRange(1, 1, 1, headers.length); + firstRowRange.setBackground('#E8F4FD'); + firstRowRange.setHorizontalAlignment('center'); + firstRowRange.setVerticalAlignment('middle'); + } + + /** + * タスクが期日切れかどうかを判定 + */ + _isOverdue(task) { + if (!task.end_date || task.progress === 100) return false; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const endDate = new Date(task.end_date); + endDate.setHours(0, 0, 0, 0); + + const isOverdue = endDate < today; + return isOverdue; + } + + /** + * タスク情報を描画 + */ + _renderTaskInfo() { + const startRow = 4; // 月1行 + 日(列名)1行 + 空白行1行 + データ開始 + + const taskData = this.tasks.map(task => { + // 親タスク名と子タスク名を設定 + let parentTaskName = ''; + let childTaskName = ''; + + if (task.parent_task_id) { + // 子タスクの場合:B列は空白、C列に子タスク名 + parentTaskName = ''; + childTaskName = task.task_name; + } else { + // 親タスクの場合:B列に親タスク名、C列は空白 + parentTaskName = task.task_name; + childTaskName = ''; + } + + // タグは配列の場合、「フェーズ」以外の最初の1つ + let tag = ''; + if (Array.isArray(task.tags) && task.tags.length > 0) { + // 「フェーズ」以外のタグを探す + const validTags = task.tags.filter(t => t !== 'フェーズ'); + tag = validTags.length > 0 ? validTags[0] : ''; + } else if (task.tags && task.tags !== 'フェーズ') { + tag = task.tags; + } + + // ステータスを進捗率から判定 + const progress = task.progress || 0; + let status = ''; + if (progress === 0) { + status = '未着手'; + } else if (progress === 100) { + status = '完了'; + } else { + status = '進行中'; + } + + return [ + task.task_id, + parentTaskName, + childTaskName, + tag, + status, + task.start_date || '', + task.end_date || '', + progress / 100, // 0.5 = 50% (条件付き書式用) + task.assignee || '', + task.priority || '', + task.estimated_hours || '', // 数値のみ + task.actual_hours || '' // 実績工数 + ]; + }); + + if (taskData.length > 0) { + this.sheet.getRange(startRow, 1, taskData.length, taskData[0].length).setValues(taskData); + + // 親タスク(parent_task_idがnull)を太字にする(1行全体) + // タイムライン最後の列を計算 + const dateRange = this._calculateDateRange(); + + // 日付を正規化して日数を計算 + const startDate = new Date(dateRange.start); + startDate.setHours(0, 0, 0, 0); + const endDate = new Date(dateRange.end); + endDate.setHours(0, 0, 0, 0); + const daysDiff = Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1; + + const lastCol = CONFIG.GANTT.START_COLUMN + daysDiff; + + // 全タスク行の背景色をクリア(過去の色付けをリセット) + const allTasksRange = this.sheet.getRange(startRow, 1, this.tasks.length, lastCol); + allTasksRange.setBackground(null); + + // 全タスクの終了日セル(G列)の背景色と文字色をリセット + const endDateCol = this.sheet.getRange(startRow, 7, this.tasks.length, 1); + endDateCol.setBackground('#FFFFFF'); // 白背景 + endDateCol.setFontColor('#000000'); // 黒文字 + endDateCol.setFontWeight('normal'); // 通常の太さ + + for (let i = 0; i < this.tasks.length; i++) { + if (!this.tasks[i].parent_task_id) { + // タスク行全体を太字に(A列からタイムライン最後まで) + const rowRange = this.sheet.getRange(startRow + i, 1, 1, lastCol); + rowRange.setFontWeight('bold'); + } + + // 期日切れタスクの終了日セル(G列)を強調 + if (this._isOverdue(this.tasks[i])) { + const endDateCell = this.sheet.getRange(startRow + i, 7); + endDateCell.setBackground('#FF0000'); // 赤背景 + endDateCell.setFontColor('#FFFF00'); // 黄色文字 + endDateCell.setFontWeight('bold'); // 太字 + } + } + + // ステータス列(E列)に条件付き書式で色分け + const statusCol = this.sheet.getRange(startRow, 5, taskData.length, 1); + let rules = this.sheet.getConditionalFormatRules(); + + // 未着手 = グレー + const ruleNotStarted = SpreadsheetApp.newConditionalFormatRule() + .whenTextEqualTo('未着手') + .setBackground('#E0E0E0') + .setRanges([statusCol]) + .build(); + rules.push(ruleNotStarted); + + // 進行中 = 黄色 + const ruleInProgress = SpreadsheetApp.newConditionalFormatRule() + .whenTextEqualTo('進行中') + .setBackground('#FFF59D') + .setRanges([statusCol]) + .build(); + rules.push(ruleInProgress); + + // 完了 = 緑(E列のみ) + const ruleCompleted = SpreadsheetApp.newConditionalFormatRule() + .whenTextEqualTo('完了') + .setBackground('#A5D6A7') + .setRanges([statusCol]) + .build(); + rules.push(ruleCompleted); + + // 中断 = 赤(E列のみ) + const ruleInterrupted = SpreadsheetApp.newConditionalFormatRule() + .whenTextEqualTo('中断') + .setBackground('#FF6B6B') + .setRanges([statusCol]) + .build(); + rules.push(ruleInterrupted); + + // 完了・中断の行全体をグレーにする(全列対象) + const allRowsRange = this.sheet.getRange(startRow, 1, taskData.length, lastCol); + + // 完了行全体 = グレー + const ruleCompletedRow = SpreadsheetApp.newConditionalFormatRule() + .whenFormulaSatisfied(`=$E${startRow}="完了"`) + .setBackground('#D3D3D3') + .setRanges([allRowsRange]) + .build(); + rules.push(ruleCompletedRow); + + // 中断行全体 = グレー + const ruleInterruptedRow = SpreadsheetApp.newConditionalFormatRule() + .whenFormulaSatisfied(`=$E${startRow}="中断"`) + .setBackground('#D3D3D3') + .setRanges([allRowsRange]) + .build(); + rules.push(ruleInterruptedRow); + + // 日付列(F列・G列)のフォーマットを「yy/mm/dd」に設定 + const dateColStart = this.sheet.getRange(startRow, 6, taskData.length, 1); // F列(開始日) + const dateColEnd = this.sheet.getRange(startRow, 7, taskData.length, 1); // G列(終了日) + dateColStart.setNumberFormat('yy/mm/dd'); + dateColEnd.setNumberFormat('yy/mm/dd'); + + // 進捗列(H列)に条件付き書式でバー表示 + const progressCol = this.sheet.getRange(startRow, 8, taskData.length, 1); + progressCol.setNumberFormat('0%'); // パーセント表示 + + // データバーの条件付き書式を追加 + const ruleProgress = SpreadsheetApp.newConditionalFormatRule() + .setGradientMaxpointWithValue('#4A90E2', SpreadsheetApp.InterpolationType.NUMBER, '1') + .setGradientMinpointWithValue('#FFFFFF', SpreadsheetApp.InterpolationType.NUMBER, '0') + .setRanges([progressCol]) + .build(); + rules.push(ruleProgress); + + this.sheet.setConditionalFormatRules(rules); + } + } + + /** + * Ganttバーを描画 + */ + _renderGanttBars(dateRange) { + const startCol = CONFIG.GANTT.START_COLUMN; + const startRow = 4; // タスクデータ開始行に合わせる + + this.tasks.forEach((task, index) => { + if (!task.start_date || !task.end_date) return; + + const taskStart = new Date(task.start_date); + const taskEnd = new Date(task.end_date); + + // タスクの開始位置と期間を計算 + const daysFromStart = Math.ceil((taskStart - dateRange.start) / (1000 * 60 * 60 * 24)); + const duration = Math.ceil((taskEnd - taskStart) / (1000 * 60 * 60 * 24)) + 1; + + if (daysFromStart >= 0 && duration > 0) { + const barRange = this.sheet.getRange( + startRow + index, + startCol + daysFromStart, + 1, + duration + ); + + // 親=赤、子=青で色分け + const color = task.parent_task_id ? '#4A90E2' : '#FF6B6B'; // 子=青、親=赤 + const progress = task.progress || 0; + + // バーの背景色を設定 + barRange.setBackground(color); + + // 進捗バーを描画(グラデーション風) + if (progress < 100) { + const progressCols = Math.ceil(duration * progress / 100); + if (progressCols > 0) { + const progressRange = this.sheet.getRange( + startRow + index, + startCol + daysFromStart, + 1, + progressCols + ); + progressRange.setBackground(this._darkenColor(color)); + } + } + + // マイルストーンの場合はダイヤモンド記号を追加 + if (task.is_milestone) { + this.sheet.getRange(startRow + index, startCol + daysFromStart).setValue('◆'); + } + + // セルに罫線を追加 + barRange.setBorder(true, true, true, true, false, false, '#000000', SpreadsheetApp.BorderStyle.SOLID_MEDIUM); + } + }); + } + + /** + * タスクの色を取得 + */ + _getTaskColor(task) { + const tags = Array.isArray(task.tags) ? task.tags : [task.tags]; + const firstTag = tags[0]; + + return CONFIG.TAG_COLORS[firstTag] || CONFIG.TAG_COLORS['デフォルト']; + } + + /** + * 色を暗くする(進捗表示用) + */ + _darkenColor(hexColor) { + // HEXからRGBに変換 + const r = parseInt(hexColor.slice(1, 3), 16); + const g = parseInt(hexColor.slice(3, 5), 16); + const b = parseInt(hexColor.slice(5, 7), 16); + + // 70%の明度に + const newR = Math.floor(r * 0.7); + const newG = Math.floor(g * 0.7); + const newB = Math.floor(b * 0.7); + + // RGBからHEXに変換 + return '#' + [newR, newG, newB].map(x => x.toString(16).padStart(2, '0')).join(''); + } + + /** + * 期間を計算 + */ + _calculateDuration(startDate, endDate) { + if (!startDate || !endDate) return ''; + + const start = new Date(startDate); + const end = new Date(endDate); + const days = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1; + + return days; // 数値のみ返す + } + + /** + * スタイルを適用 + */ + _applyStyles() { + const dataRange = this.sheet.getDataRange(); + dataRange.setVerticalAlignment('middle'); + + // グリッドを非表示 + this.sheet.setHiddenGridlines(true); + + // ID(A列)・親タスク名(B列)・子タスク名(C列)の列幅を自動調整 + this.sheet.autoResizeColumn(1); // A列(ID) + this.sheet.autoResizeColumn(2); // B列(親タスク名) + this.sheet.autoResizeColumn(3); // C列(子タスク名) + + // 行の高さを25に設定(4行目以降のタスクデータ行) + if (this.tasks.length > 0) { + this.sheet.setRowHeights(4, this.tasks.length, 25); + + // 親タスク名・子タスク名の列を左寄せ + const parentTaskNameCol = this.sheet.getRange(4, 2, this.tasks.length, 1); + parentTaskNameCol.setHorizontalAlignment('left'); + const childTaskNameCol = this.sheet.getRange(4, 3, this.tasks.length, 1); + childTaskNameCol.setHorizontalAlignment('left'); + + // タグ列に入力規則を設定 + const tagOptions = [ + '企画・計画', + '設計', + '開発・実装', + 'テスト・検証', + 'リリース・デプロイ', + '運用・保守', + 'マーケティング', + '営業・商談', + '事務・管理', + 'その他' + ]; + const tagCol = this.sheet.getRange(4, 4, this.tasks.length, 1); // D列(タグ) + const tagRule = SpreadsheetApp.newDataValidation() + .requireValueInList(tagOptions, true) // true = ドロップダウン表示 + .setAllowInvalid(true) // 選択肢以外の値も許可 + .build(); + tagCol.setDataValidation(tagRule); + + // ステータス列に入力規則を設定 + const statusOptions = [ + '未着手', + '進行中', + '完了', + '中断' + ]; + const statusCol = this.sheet.getRange(4, 5, this.tasks.length, 1); // E列(ステータス) + const statusRule = SpreadsheetApp.newDataValidation() + .requireValueInList(statusOptions, true) // true = ドロップダウン表示 + .setAllowInvalid(false) + .build(); + statusCol.setDataValidation(statusRule); + + // タグ列に色付き条件付き書式を設定 + const tagColors = CONFIG.TAG_COLORS; + + // まずタグ列全体にデフォルト色を適用 + tagCol.setBackground(tagColors['デフォルト']); + tagCol.setFontColor('#FFFFFF'); + + let rules = this.sheet.getConditionalFormatRules(); + Object.keys(tagColors).forEach(tag => { + // 'デフォルト'はスキップ(特定のタグ名ではないため) + if (tag === 'デフォルト') return; + + const rule = SpreadsheetApp.newConditionalFormatRule() + .whenTextEqualTo(tag) + .setBackground(tagColors[tag]) + .setFontColor('#FFFFFF') // 白文字 + .setRanges([tagCol]) + .build(); + rules.push(rule); + }); + this.sheet.setConditionalFormatRules(rules); + } + } + + /** + * 行の破線を適用(全列) + */ + _applyRowBorders(dateRange) { + if (this.tasks.length === 0) return; + + // タイムライン列の範囲を計算 + const daysDiff = Math.ceil((dateRange.end - dateRange.start) / (1000 * 60 * 60 * 24)); + const lastCol = CONFIG.GANTT.START_COLUMN + daysDiff; + + // 最初のタスク行(4行目)の上に破線を追加 + const firstRowRange = this.sheet.getRange(4, 1, 1, lastCol); + firstRowRange.setBorder( + true, null, null, null, // top, left, bottom, right + null, null, // vertical, horizontal + '#CCCCCC', // グレー + SpreadsheetApp.BorderStyle.DASHED // 破線 + ); + + // 各タスク行の下に破線を追加(全列) + for (let i = 0; i < this.tasks.length; i++) { + const rowRange = this.sheet.getRange(4 + i, 1, 1, lastCol); + rowRange.setBorder( + null, null, true, null, // top, left, bottom, right + null, null, // vertical, horizontal + '#CCCCCC', // グレー + SpreadsheetApp.BorderStyle.DASHED // 破線 + ); + } + } + + /** + * 不要な行を削除(タスクデータの最後の行から1行空けてそれ以降を削除) + */ + _cleanupUnusedRows() { + if (this.tasks.length === 0) return; + + // タスクデータの最後の行 = 3行(ヘッダー) + タスク数 + const lastTaskRow = 3 + this.tasks.length; + + // 1行空けた次の行から削除開始 + const deleteStartRow = lastTaskRow + 2; + + // シートの最大行数を取得 + const maxRows = this.sheet.getMaxRows(); + + // 削除する行数を計算 + const rowsToDelete = maxRows - deleteStartRow + 1; + + // 削除する行が存在する場合のみ削除 + if (rowsToDelete > 0 && deleteStartRow <= maxRows) { + this.sheet.deleteRows(deleteStartRow, rowsToDelete); + } + } +} + +/** + * ブロックされているタスクをGanttシートでハイライト + * + * 遅延タスクの影響を受けているタスクを赤色背景でハイライトし、 + * セルにメモを追加します。 + * + * @param {Sheet} sheet - Ganttシート + * @param {Object} projectData - プロジェクトデータ + * @param {Object} impactReport - analyzeDependencyImpact()の戻り値 + */ +function highlightBlockedTasks(sheet, projectData, impactReport) { + try { + // 影響を受けるタスクIDをすべて収集 + const allImpactedTaskIds = new Set(); + + for (const delayedTaskId in impactReport.impactedTasks) { + const impact = impactReport.impactedTasks[delayedTaskId]; + for (const taskId of impact.impactedTaskIds) { + allImpactedTaskIds.add(taskId); + } + } + + if (allImpactedTaskIds.size === 0) { + Logger.log('✓ ブロックされているタスクがないため、ハイライトをスキップ'); + return; + } + + Logger.log(`--- ${allImpactedTaskIds.size}件のブロックタスクをハイライト ---`); + + // タスクIDから行番号を取得(4行目から開始) + const taskMap = {}; + for (let i = 0; i < projectData.tasks.length; i++) { + const task = projectData.tasks[i]; + taskMap[task.task_id] = 4 + i; // 4行目からタスクデータが始まる + } + + // ブロックされているタスクの行をハイライト + for (const taskId of allImpactedTaskIds) { + const rowNum = taskMap[taskId]; + + if (!rowNum) { + Logger.log(`⚠ タスクID ${taskId} の行が見つかりません`); + continue; + } + + // タスク情報列(A~L列の12列)を赤色背景でハイライト + const range = sheet.getRange(rowNum, 1, 1, 12); + range.setBackground('#FFCDD2'); // 赤色背景 + + // タスク名セル(C列)にメモを追加 + const taskNameCell = sheet.getRange(rowNum, 3); + const currentNote = taskNameCell.getNote(); + const blockerNote = '⚠️ ブロック中: 依存タスクが遅延しています'; + + if (!currentNote.includes(blockerNote)) { + const newNote = currentNote ? currentNote + '\n\n' + blockerNote : blockerNote; + taskNameCell.setNote(newNote); + } + + Logger.log(` ハイライト完了: ${taskId} (行${rowNum})`); + } + + Logger.log(`✓ ${allImpactedTaskIds.size}件のタスクをハイライトしました`); + + } catch (error) { + Logger.log(`✗ ハイライトエラー: ${error.message}`); + Logger.log(`✗ スタックトレース: ${error.stack}`); + throw error; + } +} + +/** + * クリティカルパス上のタスクをGanttシート上でハイライト表示 + * + * @param {Sheet} sheet - Ganttシート + * @param {Object} projectData - プロジェクトデータ + * @param {Object} criticalPathReport - calculateCriticalPath()の結果 + */ +function highlightCriticalPath(sheet, projectData, criticalPathReport) { + if (!criticalPathReport || criticalPathReport.skipped) { + Logger.log('[INFO] Critical path analysis skipped or unavailable'); + return; + } + + if (criticalPathReport.error === 'circular_dependency') { + Logger.log('[ERROR] Cannot highlight critical path due to circular dependency'); + return; + } + + const criticalTaskIds = new Set(criticalPathReport.criticalTasks); + const nearCriticalTaskIds = new Set(criticalPathReport.nearCriticalTasks); + + // タスクID → 行番号のマップを構築(4行目から開始) + const taskMap = {}; + for (let i = 0; i < projectData.tasks.length; i++) { + taskMap[projectData.tasks[i].task_id] = 4 + i; + } + + // クリティカルパスタスクを赤色ハイライト + for (const taskId of criticalTaskIds) { + if (!taskMap[taskId]) continue; + + const rowNum = taskMap[taskId]; + const range = sheet.getRange(rowNum, 1, 1, 12); // A列~L列 + range.setBackground('#FFEBEE'); // Light red background + + // タスク名セルに太字+赤色テキスト + const taskNameCell = sheet.getRange(rowNum, 3); // C列(タスク名) + taskNameCell.setFontWeight('bold'); + taskNameCell.setFontColor('#E53935'); // Red text + + // ノートを追加 + const metrics = criticalPathReport.taskMetrics[taskId]; + const noteText = '🔴 クリティカルパス\n' + + 'スラック: 0日\n' + + '最早開始: Day ' + metrics.es + '\n' + + '最早終了: Day ' + metrics.ef; + taskNameCell.setNote(noteText); + } + + // Near-criticalタスクをオレンジ色ハイライト + for (const taskId of nearCriticalTaskIds) { + if (!taskMap[taskId]) continue; + + const rowNum = taskMap[taskId]; + const range = sheet.getRange(rowNum, 1, 1, 12); + range.setBackground('#FFF3E0'); // Light orange background + + const taskNameCell = sheet.getRange(rowNum, 3); + taskNameCell.setFontWeight('bold'); + taskNameCell.setFontColor('#FF9800'); // Orange text + + const metrics = criticalPathReport.taskMetrics[taskId]; + const noteText = '🟠 Near-Critical\n' + + 'スラック: ' + metrics.slack + '日\n' + + '最早開始: Day ' + metrics.es + '\n' + + '最遅開始: Day ' + metrics.ls; + taskNameCell.setNote(noteText); + } + + Logger.log('[INFO] Highlighted ' + criticalTaskIds.size + ' critical and ' + + nearCriticalTaskIds.size + ' near-critical tasks'); +} diff --git a/gas/README.md b/gas/README.md new file mode 100644 index 0000000..87fb315 --- /dev/null +++ b/gas/README.md @@ -0,0 +1,518 @@ +# Google Apps Script (GAS) 導入手順 + +このディレクトリには、スプレッドシートに自動的にGanttチャートを生成するGASコードが含まれています。 + +## 📋 前提条件 + +- Googleアカウント +- スプレッドシート作成済み(ID: `1y7U-3hVfdubQPh-H39k-5bvuHF7cOkHkOuA121GtxCI`) +- Discord Webhook URL設定済み + +## 🚀 セットアップ手順 + +### 1. Google Apps Scriptプロジェクトを開く + +1. スプレッドシートを開く: https://docs.google.com/spreadsheets/d/1y7U-3hVfdubQPh-H39k-5bvuHF7cOkHkOuA121GtxCI +2. メニューから「拡張機能」→「Apps Script」をクリック +3. 新しいタブでGASエディタが開きます + +### 2. スクリプトファイルをアップロード + +GASエディタで以下のファイルを作成します: + +#### **Config.gs** +1. 左側のファイル一覧で「+」ボタンをクリック → 「スクリプト」を選択 +2. ファイル名を `Config.gs` に変更 +3. `gas/Config.gs` の内容をコピーして貼り付け + +#### **SheetManager.gs** +1. 同様に新しいスクリプトを作成 +2. ファイル名を `SheetManager.gs` に変更 +3. `gas/SheetManager.gs` の内容をコピーして貼り付け + +#### **GanttRenderer.gs** +1. 同様に新しいスクリプトを作成 +2. ファイル名を `GanttRenderer.gs` に変更 +3. `gas/GanttRenderer.gs` の内容をコピーして貼り付け + +#### **Code.gs** +1. 既存の `Code.gs` ファイルの内容を削除 +2. `gas/Code.gs` の内容をコピーして貼り付け + +### 3. 権限の承認 + +1. GASエディタ上部の「実行」ボタンの隣にあるプルダウンから `testGenerateGantt` 関数を選択 +2. 「実行」ボタンをクリック +3. 初回実行時に権限の承認が必要です: + - 「権限を確認」をクリック + - Googleアカウントを選択 + - 「詳細」→「プロジェクト名(安全ではないページ)に移動」をクリック + - 「許可」をクリック + +## 🧪 テスト実行 + +### テストデータで動作確認 + +1. GASエディタで `testGenerateGantt` 関数が選択されていることを確認 +2. 「実行」ボタンをクリック +3. 実行ログを確認: + - GASエディタ下部の「実行ログ」をクリック + - ✓マークが表示されれば成功 + +### 確認事項 + +実行後、スプレッドシートで以下を確認: + +#### ✅ プロジェクト一覧シート +- シート「プロジェクト一覧」が作成されている +- プロジェクトID `0009` の情報が表示されている +- 進捗率、総工数が自動計算されている + +#### ✅ 全プロジェクトタスクシート +- シート「全プロジェクトタスク」が作成されている +- 36個のタスクが一覧表示されている + +#### ✅ Ganttチャート +- シート「Gantt_0009 - セミナー振り分けシステム」が作成されている +- タスク情報が左側に表示されている +- 右側に月/日のヘッダーが表示されている +- タスクごとにカラフルなバーが表示されている +- 担当者/優先度/タグ/工数の列がデフォルトで折りたたまれている + +#### ✅ Discord通知 +- Discordチャンネルに成功通知が届いている +- プロジェクト名、ID、タスク数、スプレッドシートURLが表示されている + +## 📊 実際のデータで実行 + +### 方法1: JSONファイルをGoogle Driveにアップロード + +1. プロジェクトのJSONファイル(例: `0009_セミナー振り分けシステム.json`)をGoogle Driveにアップロード +2. ファイルを右クリック → 「リンクを取得」→ ファイルIDをコピー + - URL例: `https://drive.google.com/file/d/{ファイルID}/view` +3. GASエディタで `processJsonFile` 関数を編集: + ```javascript + processJsonFile('ここにファイルIDを貼り付け'); + ``` +4. `processJsonFile` を実行 + +### 方法2: JSONデータを直接Code.gsに貼り付け + +1. JSONファイルの内容をコピー +2. `Code.gs` の `testGenerateGantt` 関数内の `testData` 変数を更新: + ```javascript + const testData = { + // ここにJSONの内容を貼り付け + }; + ``` +3. `testGenerateGantt` を実行 + +## 🔄 自動化 + +### 期日切れタスクのDiscord通知 + +毎日自動的に期日切れタスクをDiscordに通知する機能を設定できます。 + +#### トリガー設定手順(朝7時と夜20時に通知) + +1. GASエディタ左側の「トリガー」(時計アイコン)をクリック +2. 「トリガーを追加」をクリック +3. 以下を設定(朝7時の通知): + - 実行する関数: `sendOverdueTasksNotification` + - イベントのソース: 時間主導型 + - 時間ベースのトリガーのタイプ: 日付ベースのタイマー + - 時刻: 午前7時~8時 +4. 「保存」をクリック +5. もう一度「トリガーを追加」をクリックして夜20時の通知を設定: + - 実行する関数: `sendOverdueTasksNotification` + - イベントのソース: 時間主導型 + - 時間ベースのトリガーのタイプ: 日付ベースのタイマー + - 時刻: 午後8時~9時 +6. 「保存」をクリック + +#### 通知内容 + +- プロジェクトごとにグループ化された期日切れタスク一覧 +- タスク名、期日、遅延日数、進捗率、担当者 +- スプレッドシートへの直接リンク + +### バーンダウンチャート自動記録 + +毎日自動的にバーンダウンデータを記録する機能を設定できます。 + +#### 🚀 簡単セットアップ(推奨) + +GASエディタで以下の関数を1回実行するだけでOK: + +1. 関数選択ドロップダウンから `setupDailyBurndownTrigger` を選択 +2. 「実行」ボタンをクリック +3. 実行ログに「✅ トリガーを作成しました!」と表示されたら完了 + +これで毎日午前9時に自動記録が開始されます。 + +**停止したい場合**: `removeDailyBurndownTrigger` を実行 + +#### 手動セットアップ(従来の方法) + +
+クリックして展開 + +1. GASエディタ左側の「トリガー」(時計アイコン)をクリック +2. 「トリガーを追加」をクリック +3. 以下を設定: + - 実行する関数: `recordDailyBurndownData` + - イベントのソース: 時間主導型 + - 時間ベースのトリガーのタイプ: 日付ベースのタイマー + - 時刻: 午前9時~10時 +4. 「保存」をクリック + +
+ +#### 記録内容 + +- プロジェクトごとの予定進捗率と実績進捗率 +- 完了タスク数、残タスク数 +- ベロシティ(過去7日間の平均完了タスク数/日) +- 完了予測日 +- データは BurndownData シートに記録されます +- 90日より古いデータは自動削除されます + +### 全シート自動同期 + +毎日自動的に全Ganttシートからデータを読み取り、プロジェクト一覧・全タスク・期日切れタスクを同期する機能を設定できます。 + +#### 🚀 簡単セットアップ(推奨) + +GASエディタで以下の関数を1回実行するだけでOK: + +1. 関数選択ドロップダウンから `setupSyncAllSheetsTrigger` を選択 +2. 「実行」ボタンをクリック +3. 実行ログに「✅ トリガーを作成しました!」と表示されたら完了 + +これで毎日午前10時に自動同期が開始されます。 + +**停止したい場合**: `removeSyncAllSheetsTrigger` を実行 + +#### 同期内容 + +- 全Ganttシート(`XXXX_プロジェクト名`形式)からデータを読み取り +- プロジェクト一覧シートを更新 +- 全プロジェクトタスクシートを更新 +- 期日切れタスク一覧シートを更新 +- Ganttシートのステータスから進捗率を自動更新 + +### Google Driveフォルダ監視(今後実装予定) + +Google Driveのフォルダを監視して、新しいJSONファイルが追加されたら自動的にGanttチャートを生成する機能は今後実装予定です。 + +## 📝 カスタマイズ + +### タグの色を変更 + +`Config.gs` の `TAG_COLORS` オブジェクトを編集: + +```javascript +TAG_COLORS: { + '開発': '#4A90E2', // 青 + 'デザイン': '#E24A90', // ピンク + 'テスト': '#90E24A', // 緑 + '調査': '#E2904A', // オレンジ + 'レビュー': '#904AE2', // 紫 + 'デフォルト': '#999999' // グレー +} +``` + +### 優先度の色を変更 + +`Config.gs` の `PRIORITY_COLORS` オブジェクトを編集: + +```javascript +PRIORITY_COLORS: { + '高': '#FF6B6B', // 赤 + '中': '#FFA726', // オレンジ + '低': '#66BB6A' // 緑 +} +``` + +### Ganttチャートの表示期間を変更 + +`Config.gs` の `GANTT.DAYS_TO_SHOW` を編集: + +```javascript +GANTT: { + START_COLUMN: 10, + HEADER_ROW: 1, + DATA_START_ROW: 2, + DAYS_TO_SHOW: 180, // この数値を変更(日数) + CELL_WIDTH: 30 +} +``` + +## 🐛 トラブルシューティング + +### エラー: "ReferenceError: CONFIG is not defined" + +- `Config.gs` ファイルが正しく作成されているか確認 +- ファイル名が正確に `Config.gs` になっているか確認 +- GASエディタで「保存」ボタンをクリック + +### エラー: "Exception: スプレッドシートが見つかりません" + +- `Config.gs` の `SPREADSHEET_ID` が正しいか確認 +- スプレッドシートへのアクセス権限があるか確認 + +### Ganttバーが表示されない + +- タスクに `start_date` と `end_date` が設定されているか確認 +- 日付形式が `YYYY-MM-DD` になっているか確認 + +### Discord通知が届かない + +- `Config.gs` の `DISCORD_WEBHOOK_URL` が正しいか確認 +- Webhook URLが有効か確認 + +## 📚 ファイル構成 + +``` +gas/ +├── Config.gs # 設定ファイル(スプレッドシートID、Discord、色設定など) +├── SheetManager.gs # シート操作クラス(作成、更新、データ投入) +├── GanttRenderer.gs # Ganttチャート描画クラス(バー、ヘッダー、スタイル) +├── Code.gs # メインスクリプト(実行エントリーポイント) +└── README.md # このファイル +``` + +## 🎯 次のステップ + +1. ✅ GASスクリプトのセットアップ完了 +2. ✅ テストデータでGanttチャート生成成功 +3. 🔲 実際のプロジェクトデータで動作確認 +4. 🔲 Google Drive監視機能の実装 +5. 🔲 エラーハンドリングの強化 +6. 🔲 パフォーマンス最適化 + +## 💡 ヒント + +- **スプレッドシートURL**: https://docs.google.com/spreadsheets/d/1y7U-3hVfdubQPh-H39k-5bvuHF7cOkHkOuA121GtxCI +- **実行ログの確認**: GASエディタ → 「実行ログ」をクリック +- **スクリプトの保存**: `Ctrl+S` (Windows) / `Cmd+S` (Mac) +- **関数の実行**: `Ctrl+R` (Windows) / `Cmd+R` (Mac) + +--- + +## 🔧 clasp統合(開発者向け) + +### 概要 + +claspを使用すると、ローカル環境からGoogle Apps Scriptコードを管理できます。Git経由でのバージョン管理や、エディタでの快適な開発が可能になります。 + +### 初回セットアップ + +#### 1. clasp認証 + +```bash +npm run gas:login +``` + +- ブラウザが開き、Googleアカウントでの認証が求められます +- Apps Script APIへのアクセス許可を承認します +- 認証情報は `~/.clasprc.json` に保存されます + +**注意**: 事前に Apps Script APIを有効化してください +- URL: https://script.google.com/home/usersettings +- "Google Apps Script API" をONにする + +#### 2. GASプロジェクトから既存コードをpull + +```bash +npm run gas:pull +``` + +- GASプロジェクトからローカルにコードをダウンロード +- 既存のgas/ディレクトリのファイルと統合されます +- `appsscript.json` も自動的にダウンロードされます + +### 開発ワークフロー + +#### 通常の開発手順 + +1. **ローカルでgas/内のファイルを編集** + ```bash + vim gas/SheetManager.gs + # またはVS Codeなどのエディタで編集 + ``` + +2. **GASにpush** + ```bash + npm run gas:push + ``` + - ローカルの変更がGASプロジェクトに反映されます + - 依存関係順(Config → SheetManager → GanttRenderer → Code)でアップロードされます + +3. **GASエディタで確認** + ```bash + npm run gas:open + ``` + - ブラウザでGASエディタが開きます + - アップロードされたコードを確認できます + +4. **ログ確認** + ```bash + npm run gas:logs + ``` + - GASの実行ログがターミナルに表示されます + +#### GASエディタで編集した場合 + +GASエディタで直接編集した内容をローカルに反映する場合: + +```bash +# GASからローカルにpull +npm run gas:pull + +# 差分確認 +git diff gas/ + +# 問題なければコミット +git add gas/ +git commit -m "chore: sync from GAS editor" +``` + +### 利用可能なコマンド + +#### 基本コマンド + +- `npm run gas:push` - ローカル → GAS(コードをアップロード) +- `npm run gas:pull` - GAS → ローカル(コードをダウンロード) +- `npm run gas:open` - ブラウザでGASエディタを開く +- `npm run gas:logs` - 実行ログ表示 + +#### 開発支援コマンド + +- `npm run gas:push:watch` - ファイル変更を監視して自動push(開発時のみ) +- `npm run gas:logs:watch` - ログをリアルタイム監視 + +#### 認証管理 + +- `npm run gas:login` - clasp認証 +- `npm run gas:logout` - clasp認証解除 + +#### バージョン管理 + +- `npm run gas:version "v1.1.0 - New feature"` - GASバージョン作成 +- `npm run gas:versions` - バージョン一覧表示 + +### 推奨ワークフロー + +```bash +# 1. ローカルでファイル編集 +vim gas/Code.gs + +# 2. GASにpush +npm run gas:push + +# 3. GASエディタで動作確認 +npm run gas:open + +# 4. ログ確認(関数実行後) +npm run gas:logs + +# 5. Gitにコミット +git add gas/Code.gs +git commit -m "feat: add new feature" +git push origin main +``` + +### 自動同期モード(開発時) + +開発中にファイル変更を自動的にGASに反映する場合: + +```bash +# ターミナル1: ファイル監視 + 自動push +npm run gas:push:watch + +# ターミナル2: ログ監視 +npm run gas:logs:watch + +# Ctrl+C で監視を停止 +``` + +### トラブルシューティング + +#### エラー: `User has not enabled the Apps Script API` + +**原因**: Apps Script APIが有効化されていない + +**解決方法**: +1. https://script.google.com/home/usersettings を開く +2. "Google Apps Script API" をONにする +3. `npm run gas:login` を再実行 + +#### エラー: `Could not find script` + +**原因**: `.clasp.json` のスクリプトIDが間違っている + +**解決方法**: +1. Apps Scriptエディタ → プロジェクト設定 → スクリプトIDを確認 +2. `.clasp.json` のスクリプトIDを修正 +3. `npm run gas:pull` を再実行 + +#### 認証リセット + +認証に問題がある場合: + +```bash +# logout +npm run gas:logout + +# 認証ファイル削除(念のため) +rm ~/.clasprc.json + +# 再login +npm run gas:login +``` + +#### pushが失敗する + +**原因**: GASファイルに構文エラーがある + +**解決方法**: +1. エラーメッセージでファイル名と行番号を確認 +2. 該当ファイルを修正 +3. 再度push + +```bash +# 修正後 +npm run gas:push +``` + +### プロジェクト構成 + +``` +185-automatic-gantt-chart-creation/ +├── .clasp.json # clasp設定(スクリプトID、rootDir) +├── package.json # npm scripts(gas:*コマンド) +├── gas/ # GASファイル +│ ├── appsscript.json # GASプロジェクトメタデータ +│ ├── .claspignore # clasp除外ファイル設定 +│ ├── Config.gs +│ ├── SheetManager.gs +│ ├── GanttRenderer.gs +│ ├── Code.gs +│ └── README.md # このファイル +└── src/ # TypeScriptファイル(ローカル実行用) +``` + +### セキュリティ考慮事項 + +- `.clasp.json` はGitにコミット済み(スクリプトIDは秘匿情報ではない) +- `~/.clasprc.json` はホームディレクトリに保存(プロジェクト外) +- `Config.gs` に DISCORD_WEBHOOK_URL と TODOIST_API_KEY がハードコードされています + - 今後の改善: PropertiesService で環境変数化を検討 + +### Tips + +- **ファイルのpush順序**: Config → SheetManager → GanttRenderer → Code(依存関係順) +- **初回pullは慎重に**: 既存のgas/ディレクトリを事前にバックアップ推奨 +- **GASバージョン**: リリース時に `npm run gas:version` でバージョン作成を推奨 +- **ログ確認**: `npm run gas:logs` で直近15件のログを確認可能 diff --git a/gas/SheetManager.gs b/gas/SheetManager.gs new file mode 100644 index 0000000..1780994 --- /dev/null +++ b/gas/SheetManager.gs @@ -0,0 +1,1144 @@ +/** + * シート管理クラス + */ + +class SheetManager { + constructor() { + // スプレッドシートにバインドされたGASの場合はgetActiveSpreadsheet()を使用 + // スタンドアロンGASの場合はopenById()を使用 + try { + this.ss = SpreadsheetApp.getActiveSpreadsheet(); + if (!this.ss) { + this.ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID); + } + } catch (e) { + this.ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID); + } + } + + /** + * プロジェクト一覧シートの取得または作成 + */ + getOrCreateProjectListSheet() { + let sheet = this.ss.getSheetByName(CONFIG.SHEET_NAMES.PROJECT_LIST); + + if (!sheet) { + sheet = this.ss.insertSheet(CONFIG.SHEET_NAMES.PROJECT_LIST); + this._initializeProjectListSheet(sheet); + } + + return sheet; + } + + /** + * 全プロジェクトタスクシートの取得または作成 + */ + getOrCreateAllTasksSheet() { + let sheet = this.ss.getSheetByName(CONFIG.SHEET_NAMES.ALL_TASKS); + + if (!sheet) { + sheet = this.ss.insertSheet(CONFIG.SHEET_NAMES.ALL_TASKS); + this._initializeAllTasksSheet(sheet); + } + + return sheet; + } + + /** + * 期日切れタスクシートの取得または作成 + */ + getOrCreateOverdueTasksSheet() { + let sheet = this.ss.getSheetByName(CONFIG.SHEET_NAMES.OVERDUE_TASKS); + + if (!sheet) { + sheet = this.ss.insertSheet(CONFIG.SHEET_NAMES.OVERDUE_TASKS); + this._initializeOverdueTasksSheet(sheet); + } + + return sheet; + } + + /** + * Todoistタスクシートの取得または作成 + */ + getOrCreateTodoistTasksSheet() { + let sheet = this.ss.getSheetByName(CONFIG.SHEET_NAMES.TODOIST_TASKS); + + if (!sheet) { + sheet = this.ss.insertSheet(CONFIG.SHEET_NAMES.TODOIST_TASKS); + this._initializeTodoistTasksSheet(sheet); + } + + return sheet; + } + + /** + * プロジェクト別Ganttシートの取得または作成 + */ + getOrCreateGanttSheet(projectId, projectName) { + const sheetName = `${projectId}_${projectName}`; + let sheet = this.ss.getSheetByName(sheetName); + + if (!sheet) { + sheet = this.ss.insertSheet(sheetName); + this._initializeGanttSheet(sheet); + } + + return sheet; + } + + /** + * バーンダウンデータシートの取得または作成 + */ + getOrCreateBurndownSheet() { + const sheetName = 'BurndownData'; + let sheet = this.ss.getSheetByName(sheetName); + + if (!sheet) { + sheet = this.ss.insertSheet(sheetName); + this._initializeBurndownSheet(sheet); + Logger.log('[INFO] Created new burndown sheet: ' + sheetName); + } + + return sheet; + } + + /** + * プロジェクト一覧シートの初期化 + */ + _initializeProjectListSheet(sheet) { + const headers = [ + 'プロジェクトID', + 'プロジェクト名', + 'シートリンク', + 'GitHubリンク', + 'プロジェクト目的', + 'プロジェクトジャンル', + 'プロジェクト期日', + '総タスク数', + '完了タスク数', + '進捗率', + '総工数見積もり', + '最終更新日時' + ]; + + const headerRange = sheet.getRange(1, 1, 1, headers.length); + headerRange.setValues([headers]); + headerRange.setBackground('#4A90E2'); + headerRange.setFontColor('#FFFFFF'); + headerRange.setFontWeight('bold'); + headerRange.setHorizontalAlignment('center'); + + sheet.setFrozenRows(1); + + // 列幅を75に設定 + for (let i = 1; i <= headers.length; i++) { + sheet.setColumnWidth(i, 75); + } + } + + /** + * 全プロジェクトタスクシートの初期化 + */ + _initializeAllTasksSheet(sheet) { + const headers = [ + 'プロジェクトID', + 'プロジェクト名', + 'ID', + '親タスク名', + '子タスク名', + 'タグ', + 'ステータス', + '開始日', + '終了日', + '進捗率', + '担当者', + '優先度', + '工数見積(h)', + '実績工数(h)' + ]; + + const headerRange = sheet.getRange(1, 1, 1, headers.length); + headerRange.setValues([headers]); + headerRange.setBackground('#4A90E2'); + headerRange.setFontColor('#FFFFFF'); + headerRange.setFontWeight('bold'); + headerRange.setHorizontalAlignment('center'); + + sheet.setFrozenRows(1); + + // 列幅を75に設定 + for (let i = 1; i <= headers.length; i++) { + sheet.setColumnWidth(i, 75); + } + } + + /** + * 期日切れタスクシートの初期化 + */ + _initializeOverdueTasksSheet(sheet) { + const headers = [ + 'PID', + 'プロジェクト名', + 'TID', + '親タスク', + '子タスク', + '期日', + '担当', + '遅延日数', + '進捗率' + ]; + + const headerRange = sheet.getRange(1, 1, 1, headers.length); + headerRange.setValues([headers]); + headerRange.setBackground('#FF6B6B'); + headerRange.setFontColor('#FFFFFF'); + headerRange.setFontWeight('bold'); + headerRange.setHorizontalAlignment('center'); + + sheet.setFrozenRows(1); + + // 列幅を個別に設定 + const columnWidths = [ + 60, // PID + 200, // プロジェクト名 + 60, // TID + 300, // 親タスク + 300, // 子タスク + 80, // 期日 + 80, // 担当 + 80, // 遅延日数 + 80 // 進捗率 + ]; + + for (let i = 0; i < columnWidths.length; i++) { + sheet.setColumnWidth(i + 1, columnWidths[i]); + } + } + + /** + * Todoistタスクシートの初期化 + */ + _initializeTodoistTasksSheet(sheet) { + const headers = [ + 'タスクID', + 'タスク名', + '説明', + '期日', + '優先度', + 'ラベル', + '完了状態', + 'Todoistリンク', + '最終更新日時' + ]; + + const headerRange = sheet.getRange(1, 1, 1, headers.length); + headerRange.setValues([headers]); + headerRange.setBackground('#E37400'); + headerRange.setFontColor('#FFFFFF'); + headerRange.setFontWeight('bold'); + headerRange.setHorizontalAlignment('center'); + + sheet.setFrozenRows(1); + + // 列幅を設定 + sheet.setColumnWidth(1, 80); // タスクID + sheet.setColumnWidth(2, 200); // タスク名 + sheet.setColumnWidth(3, 250); // 説明 + sheet.setColumnWidth(4, 100); // 期日 + sheet.setColumnWidth(5, 60); // 優先度 + sheet.setColumnWidth(6, 150); // ラベル + sheet.setColumnWidth(7, 80); // 完了状態 + sheet.setColumnWidth(8, 150); // Todoistリンク + sheet.setColumnWidth(9, 150); // 最終更新日時 + } + + + /** + * Ganttシートの初期化 + * + * 注: GanttRendererが全体のレイアウトを管理するため、 + * ここでは最小限のシート設定のみを行う + */ + _initializeGanttSheet(sheet) { + // GanttRendererが以下を設定: + // - 1行目: 月(タイムライン、2色交互) + // - 2行目: 列名(A~K) + 日(タイムライン、2色交互) + // - 3行目: 空白行 + // - 4行目以降: タスクデータ(親タスクは1行全体が太字) + // - 固定行: 2行(月1 + 列名/日1) + // - 固定列: 3列(ID + 親タスク名 + 子タスク名) + + // シートの基本設定のみ実行 + sheet.setFrozenColumns(3); // ID、親タスク名、子タスク名を固定 + } + + /** + * バーンダウンシートの初期化 + */ + _initializeBurndownSheet(sheet) { + // ヘッダー行(1行目) + const headers = [ + '記録日', // A: Date + 'プロジェクト名', // B: Project Name + '予定進捗率', // C: Expected Progress (%) + '実績進捗率', // D: Actual Progress (%) + '残タスク数', // E: Remaining Tasks + '完了タスク数', // F: Completed Tasks + '総タスク数', // G: Total Tasks + 'ベロシティ', // H: Velocity (tasks/day) + '完了予測日' // I: Predicted Completion Date + ]; + + const headerRange = sheet.getRange(1, 1, 1, headers.length); + headerRange.setValues([headers]); + headerRange.setFontWeight('bold'); + headerRange.setBackground('#4A90E2'); + headerRange.setFontColor('#FFFFFF'); + + // 列幅設定 + sheet.setColumnWidth(1, 120); // 記録日 + sheet.setColumnWidth(2, 150); // プロジェクト名 + sheet.setColumnWidth(3, 100); // 予定進捗率 + sheet.setColumnWidth(4, 100); // 実績進捗率 + sheet.setColumnWidth(5, 100); // 残タスク数 + sheet.setColumnWidth(6, 100); // 完了タスク数 + sheet.setColumnWidth(7, 100); // 総タスク数 + sheet.setColumnWidth(8, 120); // ベロシティ + sheet.setColumnWidth(9, 150); // 完了予測日 + + // 条件付き書式: 実績が予定より遅れている場合は赤色 + const dataRange = sheet.getRange('D2:D1000'); + const rule = SpreadsheetApp.newConditionalFormatRule() + .whenFormulaSatisfied('=D2 t.progress === 100).length; + + // 全タスクの進捗率の平均を計算 + const totalProgress = projectData.tasks.reduce((sum, t) => sum + (t.progress || 0), 0); + const progressRate = totalTasks > 0 ? Math.round(totalProgress / totalTasks) : 0; + + const totalHours = projectData.tasks.reduce((sum, t) => sum + (parseFloat(t.estimated_hours) || 0), 0); + + // Ganttシートのリンクを生成(SheetIDマップを使用して確実に取得) + const projectIdPadded = String(projectId).padStart(4, '0'); + let sheetId = ''; + + // 全シートを検索してプロジェクトIDが一致するGanttシートを探す + const allSheets = this.ss.getSheets(); + for (const sheet of allSheets) { + const sheetName = sheet.getName(); + if (sheetName.match(/^\d{1,4}_.+$/)) { + const match = sheetName.match(/^(\d{1,4})_/); + if (match) { + const sheetProjectId = match[1].padStart(4, '0'); + if (sheetProjectId === projectIdPadded) { + sheetId = sheet.getSheetId(); + break; + } + } + } + } + + const sheetLink = sheetId ? `=HYPERLINK("#gid=${sheetId}", "シート")` : ''; + + // GitHubリンクを生成(JSONにgithub_urlがあればそれを使用) + const githubUrl = projectData.github_url || ''; + const githubLink = githubUrl ? `=HYPERLINK("${githubUrl}", "リンク")` : ''; + + const rowData = [ + projectId, + projectName, + sheetLink, + githubLink, + projectData.project_purpose || '', + projectData.project_type || projectData.project_genre || '', + projectData.project_deadline || '', + totalTasks, + completedTasks, + progressRate + '%', + totalHours, + new Date().toLocaleString('ja-JP') + ]; + + if (rowIndex === -1) { + // 新規追加 + sheet.appendRow(rowData); + } else { + // 既存更新 + sheet.getRange(rowIndex, 1, 1, rowData.length).setValues([rowData]); + } + } + + /** + * 全タスクリストを更新 + */ + updateAllTasks(projectData) { + const sheet = this.getOrCreateAllTasksSheet(); + const projectId = String(projectData.project_id); // 文字列に統一 + const projectName = projectData.project_name; + + // 既存のプロジェクトのタスクを削除 + const data = sheet.getDataRange().getValues(); + const rowsToDelete = []; + + // 検索用に数値化("0009" → 9, "9" → 9) + const projectIdNum = parseInt(projectId, 10); + + for (let i = data.length - 1; i >= 1; i--) { + const existingIdNum = parseInt(String(data[i][0]), 10); + if (existingIdNum === projectIdNum) { // 数値として比較 + rowsToDelete.push(i + 1); + } + } + + if (rowsToDelete.length > 0) { + rowsToDelete.forEach(row => { + sheet.deleteRow(row); + }); + } + + // 新しいタスクを追加(親タスク名・子タスク名を分離) + const taskRows = projectData.tasks.map(task => { + // 親タスク名と子タスク名を設定 + let parentTaskName = ''; + let childTaskName = ''; + + if (task.parent_task_id) { + // 子タスクの場合:B列に親タスク名、C列に子タスク名 + const parentTask = projectData.tasks.find(t => t.task_id === task.parent_task_id); + parentTaskName = parentTask ? parentTask.task_name : ''; + childTaskName = task.task_name; + } else { + // 親タスクの場合:B列に親タスク名、C列は空白 + parentTaskName = task.task_name; + childTaskName = ''; + } + + // タグは最初の1つだけ(配列の場合) + let tag = ''; + if (Array.isArray(task.tags) && task.tags.length > 0) { + tag = task.tags[0]; + } else if (task.tags) { + tag = task.tags; + } + + // ステータスはタスクデータから取得(デフォルトは進捗率から判定) + const progress = task.progress || 0; + let status = task.status || ''; + + // ステータスが空の場合のみ進捗率から判定 + if (!status) { + if (progress === 0) { + status = '未着手'; + } else if (progress === 100) { + status = '完了'; + } else { + status = '進行中'; + } + } + + return [ + projectId, + projectName, + task.task_id, + parentTaskName, + childTaskName, + tag, + status, + task.start_date || '', + task.end_date || '', + progress / 100, // 進捗率を0.5 = 50%形式で + task.assignee || '', + task.priority || '', + task.estimated_hours || '', + task.actual_hours || '' + ]; + }); + + if (taskRows.length > 0) { + const startRow = sheet.getLastRow() + 1; + sheet.getRange(startRow, 1, taskRows.length, taskRows[0].length).setValues(taskRows); + + // 書式と色を適用 + this._applyAllTasksFormatting(sheet, startRow, taskRows.length); + } + } + + /** + * 全タスクリストに書式と色を適用 + */ + _applyAllTasksFormatting(sheet, startRow, numRows) { + // タグ列(F列 = 6列目)に色付き条件付き書式を設定 + const tagCol = sheet.getRange(startRow, 6, numRows, 1); + const tagColors = CONFIG.TAG_COLORS; + + // まずタグ列全体にデフォルト色を適用 + tagCol.setBackground(tagColors['デフォルト']); + tagCol.setFontColor('#FFFFFF'); + + let rules = sheet.getConditionalFormatRules(); + + Object.keys(tagColors).forEach(tag => { + // 'デフォルト'はスキップ(特定のタグ名ではないため) + if (tag === 'デフォルト') return; + + const rule = SpreadsheetApp.newConditionalFormatRule() + .whenTextEqualTo(tag) + .setBackground(tagColors[tag]) + .setFontColor('#FFFFFF') + .setRanges([tagCol]) + .build(); + rules.push(rule); + }); + + // ステータス列(G列 = 7列目)に色付き条件付き書式を設定 + const statusCol = sheet.getRange(startRow, 7, numRows, 1); + + const ruleNotStarted = SpreadsheetApp.newConditionalFormatRule() + .whenTextEqualTo('未着手') + .setBackground('#E0E0E0') + .setRanges([statusCol]) + .build(); + rules.push(ruleNotStarted); + + const ruleInProgress = SpreadsheetApp.newConditionalFormatRule() + .whenTextEqualTo('進行中') + .setBackground('#FFF59D') + .setRanges([statusCol]) + .build(); + rules.push(ruleInProgress); + + const ruleCompleted = SpreadsheetApp.newConditionalFormatRule() + .whenTextEqualTo('完了') + .setBackground('#A5D6A7') + .setRanges([statusCol]) + .build(); + rules.push(ruleCompleted); + + const ruleInterrupted = SpreadsheetApp.newConditionalFormatRule() + .whenTextEqualTo('中断') + .setBackground('#FF6B6B') + .setRanges([statusCol]) + .build(); + rules.push(ruleInterrupted); + + // 進捗率列(J列 = 10列目)にデータバー表示 + const progressCol = sheet.getRange(startRow, 10, numRows, 1); + progressCol.setNumberFormat('0%'); + + const ruleProgress = SpreadsheetApp.newConditionalFormatRule() + .setGradientMaxpointWithValue('#4A90E2', SpreadsheetApp.InterpolationType.NUMBER, '1') + .setGradientMinpointWithValue('#FFFFFF', SpreadsheetApp.InterpolationType.NUMBER, '0') + .setRanges([progressCol]) + .build(); + rules.push(ruleProgress); + + // 日付列(H列・I列 = 8,9列目)のフォーマット + const dateColStart = sheet.getRange(startRow, 8, numRows, 1); + const dateColEnd = sheet.getRange(startRow, 9, numRows, 1); + dateColStart.setNumberFormat('yy/mm/dd'); + dateColEnd.setNumberFormat('yy/mm/dd'); + + sheet.setConditionalFormatRules(rules); + } + + /** + * Ganttシートのステータスから進捗率を更新 + */ + updateGanttProgressFromStatus(sheet) { + try { + const lastRow = sheet.getLastRow(); + if (lastRow < 4) return; + + // タスクデータ範囲(4行目以降、E列=ステータス、H列=進捗率) + const statusRange = sheet.getRange(4, 5, lastRow - 3, 1); // E列 + const progressRange = sheet.getRange(4, 8, lastRow - 3, 1); // H列 + + const statuses = statusRange.getValues(); + const progresses = progressRange.getValues(); + + let updatedCount = 0; + + for (let i = 0; i < statuses.length; i++) { + const status = String(statuses[i][0] || ''); + let currentProgress = progresses[i][0] || 0; + + // 数値に変換(0.5 → 0.5, 50% → 0.5, 50 → 50) + if (typeof currentProgress === 'string') { + currentProgress = parseFloat(currentProgress.replace('%', '')) / 100; + } else if (currentProgress > 1) { + currentProgress = currentProgress / 100; + } + + let newProgress = currentProgress; + + // ステータスに応じて進捗率を更新 + if (status === '未着手') { + newProgress = 0; + } else if (status === '完了') { + newProgress = 1; // 100% + } else if (status === '進行中' && currentProgress === 0) { + newProgress = 0.5; // 進行中で0%の場合は50%に設定 + } + // 中断や他のステータスは現在の値を保持 + + // 変更があった場合のみ更新 + if (newProgress !== currentProgress) { + progresses[i][0] = newProgress; + updatedCount++; + } + } + + if (updatedCount > 0) { + progressRange.setValues(progresses); + } + + } catch (error) { + Logger.log(`✗ 進捗率更新エラー: ${error.message}`); + } + } + + /** + * Ganttシートからプロジェクトデータを読み取る + */ + readProjectDataFromSheet(sheet) { + try { + const sheetName = sheet.getName(); + + // シート名から project_id と project_name を抽出(X_プロジェクト名 または XXXX_プロジェクト名形式) + const match = sheetName.match(/^(\d{1,4})_(.+)$/); + if (!match) { + Logger.log(`⚠ シート名が正しい形式ではありません: ${sheetName}`); + return null; + } + + // IDを4桁にゼロ埋め(例: "9" → "0009") + const projectId = match[1].padStart(4, '0'); + const projectName = match[2]; + + // タスクデータを読み取り(4行目以降、A~J列) + const lastRow = sheet.getLastRow(); + if (lastRow < 4) { + Logger.log(`⚠ タスクデータが存在しません: ${sheetName}`); + return null; + } + + const taskDataRange = sheet.getRange(4, 1, lastRow - 3, 12); // A~L列(12列) + const taskData = taskDataRange.getValues(); + + // タスクオブジェクトを構築 + const tasks = []; + const taskMap = {}; // task_id -> task の対応表(親タスク検索用) + + for (let i = 0; i < taskData.length; i++) { + const row = taskData[i]; + + // IDが空の行はスキップ + if (!row[0]) continue; + + // 親タスク名(B列 = row[1])と子タスク名(C列 = row[2]) + const parentTaskName = row[1] || ''; + const childTaskName = row[2] || ''; + + // タスク名を決定(子タスクがある場合は子タスク名、なければ親タスク名) + const taskName = childTaskName || parentTaskName; + + // 親タスクIDの判定(子タスク名があり親タスク名が空の場合は子タスク) + let parentTaskId = null; + if (childTaskName && !parentTaskName) { + // 子タスクの場合:直前の親タスク(B列に値があり、C列が空白)を探す + for (let j = i - 1; j >= 0; j--) { + const prevRow = taskData[j]; + const prevParentTaskName = prevRow[1] || ''; + const prevChildTaskName = prevRow[2] || ''; + + // 直前の親タスク(B列に値があり、C列が空白) + if (prevParentTaskName && !prevChildTaskName) { + parentTaskId = String(prevRow[0] || ''); + break; + } + } + } + + // タグを配列に変換(D列 = row[3]) + const tag = row[3] || ''; + const tags = tag ? [tag] : []; + + // 進捗率(H列 = row[7]) + // スプレッドシートでは0.5または50%のように表示される + let progressValue = row[7] || 0; + if (typeof progressValue === 'number') { + // 0.5のような小数の場合は100倍 + if (progressValue <= 1) { + progressValue = progressValue * 100; + } + } else if (typeof progressValue === 'string') { + // "50%"のような文字列の場合は数値に変換 + progressValue = parseFloat(progressValue.replace('%', '')) || 0; + } + const progress = Math.round(progressValue); + + // 日付をフォーマット(YYYY-MM-DD形式に変換) + const formatDate = (dateValue) => { + if (!dateValue) return ''; + if (dateValue instanceof Date) { + const year = dateValue.getFullYear(); + const month = String(dateValue.getMonth() + 1).padStart(2, '0'); + const day = String(dateValue.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + return String(dateValue); + }; + + tasks.push({ + task_id: String(row[0] || ''), // A列 + task_name: taskName, // B列または C列 + start_date: formatDate(row[5]), // F列 + end_date: formatDate(row[6]), // G列 + assignee: String(row[8] || ''), // I列 + priority: String(row[9] || ''), // J列 + tags: tags, // D列 + estimated_hours: parseFloat(row[10]) || 0, // K列 + actual_hours: parseFloat(row[11]) || 0, // L列 + progress: progress, // H列 + status: String(row[4] || '未着手'), // E列 - ステータス + parent_task_id: parentTaskId, + dependencies: [], // シートには保存されていない + is_milestone: false, // シートには保存されていない + description: '' // シートには保存されていない + }); + } + + // プロジェクトデータを構築 + const projectData = { + project_id: projectId, + project_name: projectName, + project_purpose: '', + project_type: '', + project_deadline: '', + github_url: '', + tasks: tasks + }; + + // プロジェクト一覧から追加情報を取得 + this._enrichProjectDataFromList(projectData); + + return projectData; + + } catch (error) { + Logger.log(`✗ シートからの読み取りエラー: ${error.message}`); + return null; + } + } + + /** + * プロジェクト一覧からプロジェクトデータを補完 + */ + _enrichProjectDataFromList(projectData) { + try { + const listSheet = this.getOrCreateProjectListSheet(); + const data = listSheet.getDataRange().getValues(); + const projectId = String(projectData.project_id); // 文字列に統一 + + // 検索用に数値化("0009" → 9, "9" → 9) + const projectIdNum = parseInt(projectId, 10); + + // プロジェクトIDで検索 + for (let i = 1; i < data.length; i++) { + const existingIdNum = parseInt(String(data[i][0]), 10); + if (existingIdNum === projectIdNum) { // 数値として比較 + // プロジェクト一覧から情報を補完 + projectData.project_purpose = data[i][4] || ''; + projectData.project_type = data[i][5] || ''; + projectData.project_deadline = data[i][6] || ''; + // GitHubリンクはHYPERLINK関数から抽出(複雑なため省略) + break; + } + } + } catch (error) { + Logger.log(`⚠ プロジェクト一覧からの情報補完に失敗: ${error.message}`); + } + } + + /** + * 期日切れタスクを更新 + */ + updateOverdueTasks() { + try { + const sheet = this.getOrCreateOverdueTasksSheet(); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // 既存データをクリア(ヘッダー以外) + const lastRow = sheet.getLastRow(); + if (lastRow > 1) { + // 行削除ではなく範囲クリアを使用(固定行すべて削除エラー回避) + sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()).clearContent(); + } + + // ヘッダーを再設定(フォーマット更新のため) + this._initializeOverdueTasksSheet(sheet); + + // 全プロジェクトタスクシートからデータを取得 + const allTasksSheet = this.getOrCreateAllTasksSheet(); + const allTasksLastRow = allTasksSheet.getLastRow(); + + if (allTasksLastRow < 2) { + Logger.log('全プロジェクトタスクシートにデータがありません。'); + return 0; + } + + // 全プロジェクトタスクシートから全データを取得(2行目以降、13列) + // 列: プロジェクトID(0), プロジェクト名(1), ID(2), 親タスク名(3), 子タスク名(4), タグ(5), ステータス(6), 開始日(7), 終了日(8), 進捗率(9), 担当者(10), 優先度(11), 工数見積(12) + const allTasksData = allTasksSheet.getRange(2, 1, allTasksLastRow - 1, 13).getValues(); + + // プロジェクトIDからシートIDへのマッピングを作成 + const sheetIdMap = {}; + const allSheets = this.ss.getSheets(); + for (const ganttSheet of allSheets) { + const sheetName = ganttSheet.getName(); + if (sheetName.match(/^\d{1,4}_.+$/)) { + const match = sheetName.match(/^(\d{1,4})_/); + if (match) { + const projectId = match[1].padStart(4, '0'); + sheetIdMap[projectId] = ganttSheet.getSheetId(); + } + } + } + + // 期日切れタスクをフィルタリング + const overdueTasksData = []; + + for (const row of allTasksData) { + const projectId = String(row[0] || ''); + const projectName = String(row[1] || ''); + const taskId = String(row[2] || ''); + const parentTaskName = String(row[3] || ''); + const childTaskName = String(row[4] || ''); + const status = String(row[6] || '未着手'); + const endDate = row[8]; // 終了日(期日) + let progressValue = row[9] || 0; // 進捗率 + const assignee = String(row[10] || ''); + + // タスクIDが空の場合、または「T」で始まらない場合はスキップ + if (!taskId || !taskId.startsWith('T')) continue; + + // 期日が設定されていない場合はスキップ + if (!endDate || !(endDate instanceof Date)) continue; + + // ステータスが「完了」または「中断」の場合はスキップ + if (status === '完了' || status === '中断') continue; + + // 期日切れかどうかを判定 + const endDateObj = new Date(endDate); + endDateObj.setHours(0, 0, 0, 0); + + if (endDateObj < today) { + // 遅延日数を計算 + const daysOverdue = Math.floor((today - endDateObj) / (1000 * 60 * 60 * 24)); + + // 進捗率を正規化 + if (typeof progressValue === 'number') { + if (progressValue <= 1) { + progressValue = progressValue * 100; + } + } else if (typeof progressValue === 'string') { + progressValue = parseFloat(progressValue.replace('%', '')) || 0; + } + const progress = Math.round(progressValue); + + // シートIDを取得(プロジェクトIDを4桁にパディングしてマッチング) + const projectIdPadded = String(projectId).padStart(4, '0'); + const sheetId = sheetIdMap[projectIdPadded] || 0; + + // 期日切れタスクデータを追加 + overdueTasksData.push([ + projectId, + projectName, + taskId, + parentTaskName, + childTaskName, + endDate, + assignee, + daysOverdue, + progress / 100, // 0.0-1.0形式に正規化 + sheetId // HYPERLINKのため + ]); + } + } + + // 期日切れタスクをシートに書き込み + if (overdueTasksData.length > 0) { + // 遅延日数の降順でソート + overdueTasksData.sort((a, b) => b[7] - a[7]); + + const startRow = 2; + + // データを書き込む(シートIDを除く9列分) + const dataToWrite = overdueTasksData.map(row => row.slice(0, 9)); + sheet.getRange(startRow, 1, dataToWrite.length, dataToWrite[0].length).setValues(dataToWrite); + + // プロジェクトIDセルにHYPERLINKを設定 + for (let i = 0; i < overdueTasksData.length; i++) { + const projectId = overdueTasksData[i][0]; + const sheetId = overdueTasksData[i][9]; // シートID + const spreadsheetId = this.ss.getId(); + const hyperlink = `=HYPERLINK("https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit#gid=${sheetId}", "${projectId}")`; + sheet.getRange(startRow + i, 1).setFormula(hyperlink); + } + + // 書式設定 + this._applyOverdueTasksFormatting(sheet, startRow, dataToWrite.length); + } + + Logger.log(`✓ 期日切れタスク更新完了: ${overdueTasksData.length}件`); + return overdueTasksData.length; + + } catch (error) { + Logger.log(`✗ 期日切れタスク更新エラー: ${error.message}`); + throw error; + } + } + + /** + * 期日切れタスクシートに書式を適用 + */ + _applyOverdueTasksFormatting(sheet, startRow, numRows) { + // 遅延日数列(H列 = 8列目)を赤色で強調 + const delayCol = sheet.getRange(startRow, 8, numRows, 1); + delayCol.setBackground('#FFCDD2'); + delayCol.setFontColor('#B71C1C'); + delayCol.setFontWeight('bold'); + delayCol.setHorizontalAlignment('center'); + + // 進捗率列(I列 = 9列目)にパーセント表示とデータバー + const progressCol = sheet.getRange(startRow, 9, numRows, 1); + progressCol.setNumberFormat('0%'); + + let rules = sheet.getConditionalFormatRules(); + const ruleProgress = SpreadsheetApp.newConditionalFormatRule() + .setGradientMaxpointWithValue('#4A90E2', SpreadsheetApp.InterpolationType.NUMBER, '1') + .setGradientMinpointWithValue('#FFFFFF', SpreadsheetApp.InterpolationType.NUMBER, '0') + .setRanges([progressCol]) + .build(); + rules.push(ruleProgress); + + // 期日列(F列 = 6列目)を日付フォーマット + const dateCol = sheet.getRange(startRow, 6, numRows, 1); + dateCol.setNumberFormat('yy/mm/dd'); + + sheet.setConditionalFormatRules(rules); + } + + /** + * Todoistタスクを更新 + * @param {Array} tasks - Todoist APIから取得したタスク配列 + */ + updateTodoistTasks(tasks) { + try { + const sheet = this.getOrCreateTodoistTasksSheet(); + + // 既存データをクリア(ヘッダー以外) + const lastRow = sheet.getLastRow(); + if (lastRow > 1) { + // 行削除ではなく範囲クリアを使用(固定行すべて削除エラー回避) + sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()).clearContent(); + } + + // ヘッダーを再設定 + this._initializeTodoistTasksSheet(sheet); + + if (!tasks || tasks.length === 0) { + Logger.log('Todoistタスクがありません。'); + return 0; + } + + // タスクデータを整形 + const taskData = []; + const now = new Date(); + + for (const task of tasks) { + const taskId = task.id || ''; + const taskName = task.content || ''; + const description = task.description || ''; + + // 期日の処理 + let dueDate = ''; + if (task.due) { + if (task.due.datetime) { + dueDate = new Date(task.due.datetime); + } else if (task.due.date) { + dueDate = new Date(task.due.date); + } + } + + // 優先度の変換(TodoistのAPI: 1=通常, 2=中, 3=高, 4=最高 → 表示: 低/中/高/最高) + let priority = '通常'; + if (task.priority === 4) priority = '最高'; + else if (task.priority === 3) priority = '高'; + else if (task.priority === 2) priority = '中'; + + // ラベルの結合 + const labels = task.labels ? task.labels.join(', ') : ''; + + // 完了状態 + const isCompleted = task.is_completed ? '完了' : '未完了'; + + // Todoistリンク + const todoistUrl = task.url || `https://todoist.com/app/task/${taskId}`; + + taskData.push([ + String(taskId), + taskName, + description, + dueDate || '', + priority, + labels, + isCompleted, + todoistUrl, + now + ]); + } + + // 期日で昇順ソート(期日が近いものが上) + taskData.sort((a, b) => { + const dateA = a[3]; // 期日列 + const dateB = b[3]; + + // 期日が空の場合は最後に配置 + if (!dateA && !dateB) return 0; + if (!dateA) return 1; + if (!dateB) return -1; + + // 日付を比較 + return new Date(dateA) - new Date(dateB); + }); + + // シートに書き込む + if (taskData.length > 0) { + const startRow = 2; + sheet.getRange(startRow, 1, taskData.length, 9).setValues(taskData); + + // TodoistリンクにHYPERLINK式を設定 + for (let i = 0; i < taskData.length; i++) { + const url = taskData[i][7]; + const taskName = taskData[i][1]; + const hyperlink = `=HYPERLINK("${url}", "${taskName}")`; + sheet.getRange(startRow + i, 8).setFormula(hyperlink); + } + + // 書式設定 + this._applyTodoistTasksFormatting(sheet, startRow, taskData.length); + } + + Logger.log(`✓ Todoistタスク更新完了: ${taskData.length}件`); + return taskData.length; + + } catch (error) { + Logger.log(`✗ Todoistタスク更新エラー: ${error.message}`); + throw error; + } + } + + /** + * Todoistタスクシートに書式を適用 + */ + _applyTodoistTasksFormatting(sheet, startRow, numRows) { + // 期日列(D列 = 4列目)を日付フォーマット + const dateCol = sheet.getRange(startRow, 4, numRows, 1); + dateCol.setNumberFormat('yy/mm/dd'); + + // 優先度列(E列 = 5列目)に条件付き書式 + const priorityCol = sheet.getRange(startRow, 5, numRows, 1); + priorityCol.setHorizontalAlignment('center'); + + let rules = sheet.getConditionalFormatRules(); + + // 最高優先度(赤) + const ruleHighest = SpreadsheetApp.newConditionalFormatRule() + .whenTextEqualTo('最高') + .setBackground('#FFCDD2') + .setFontColor('#B71C1C') + .setRanges([priorityCol]) + .build(); + rules.push(ruleHighest); + + // 高優先度(オレンジ) + const ruleHigh = SpreadsheetApp.newConditionalFormatRule() + .whenTextEqualTo('高') + .setBackground('#FFE0B2') + .setFontColor('#E65100') + .setRanges([priorityCol]) + .build(); + rules.push(ruleHigh); + + // 中優先度(黄色) + const ruleMedium = SpreadsheetApp.newConditionalFormatRule() + .whenTextEqualTo('中') + .setBackground('#FFF9C4') + .setFontColor('#F57F17') + .setRanges([priorityCol]) + .build(); + rules.push(ruleMedium); + + // 完了状態列(G列 = 7列目)に条件付き書式 + const statusCol = sheet.getRange(startRow, 7, numRows, 1); + statusCol.setHorizontalAlignment('center'); + + const ruleCompleted = SpreadsheetApp.newConditionalFormatRule() + .whenTextEqualTo('完了') + .setBackground('#C8E6C9') + .setFontColor('#2E7D32') + .setRanges([statusCol]) + .build(); + rules.push(ruleCompleted); + + // 最終更新日時列(I列 = 9列目)を日時フォーマット + const timestampCol = sheet.getRange(startRow, 9, numRows, 1); + timestampCol.setNumberFormat('yy/mm/dd hh:mm'); + + sheet.setConditionalFormatRules(rules); + } +} diff --git a/gas/appsscript.json b/gas/appsscript.json new file mode 100644 index 0000000..3a0604d --- /dev/null +++ b/gas/appsscript.json @@ -0,0 +1,7 @@ +{ + "timeZone": "Asia/Tokyo", + "dependencies": { + }, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8" +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..dd8c399 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "automatic-gantt-chart-creation", + "version": "1.0.0", + "description": "Claude Code対話型Ganttチャート自動生成システム", + "main": "src/gantt-helper.ts", + "scripts": { + "gantt:init": "ts-node src/gantt-helper.ts init", + "gantt:save": "ts-node src/gantt-helper.ts save", + "gantt:auth": "ts-node src/gantt-helper.ts auth", + "gantt:status": "ts-node src/gantt-helper.ts status", + "gantt:upload": "ts-node src/upload-to-drive.ts", + "build": "tsc", + "dev": "ts-node src/gantt-helper.ts", + "gas:login": "npx clasp login", + "gas:logout": "npx clasp logout", + "gas:pull": "npx clasp pull", + "gas:push": "npx clasp push", + "gas:push:watch": "npx clasp push --watch", + "gas:open": "npx clasp open", + "gas:logs": "npx clasp logs", + "gas:logs:watch": "npx clasp logs --watch", + "gas:version": "npx clasp version", + "gas:versions": "npx clasp versions" + }, + "keywords": [ + "gantt", + "project-management", + "google-drive", + "automation" + ], + "author": "", + "license": "MIT", + "dependencies": { + "dotenv": "^16.4.5", + "googleapis": "^128.0.0" + }, + "devDependencies": { + "@google/clasp": "^3.1.3", + "@types/node": "^20.11.0", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/src/gantt-helper.ts b/src/gantt-helper.ts new file mode 100644 index 0000000..b75f5d6 --- /dev/null +++ b/src/gantt-helper.ts @@ -0,0 +1,582 @@ +#!/usr/bin/env ts-node + +/** + * Ganttチャート自動生成ヘルパースクリプト + * + * 対話の状態管理、JSON生成、Google Driveアップロード、対話履歴保存を担当 + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { google } from 'googleapis'; +import * as dotenv from 'dotenv'; +import * as https from 'https'; +import * as http from 'http'; +import * as url from 'url'; + +// 環境変数読み込み +dotenv.config(); + +// 型定義 +interface Task { + task_id: string; + task_name: string; + start_date: string; + end_date: string; + assignee: string; + dependencies: string[]; + progress: number; + priority: string; + parent_task_id: string | null; + tags: string[]; + estimated_hours: number; + is_milestone: boolean; +} + +interface ProjectData { + project_id: string; + project_name: string; + project_purpose: string; + project_type: string; + project_deadline: string; + github_url?: string; + tasks: Task[]; +} + +interface DialogueEntry { + timestamp: string; + speaker: 'user' | 'assistant'; + message: string; +} + +/** + * 対話状態管理クラス + */ +class GanttDialogueManager { + private projectData: Partial = {}; + private dialogueHistory: DialogueEntry[] = []; + private currentPhase: number = 1; + private tasks: Task[] = []; + private taskCounter: number = 1; + + /** + * 対話履歴を追加 + */ + addDialogue(speaker: 'user' | 'assistant', message: string): void { + this.dialogueHistory.push({ + timestamp: new Date().toISOString(), + speaker, + message + }); + } + + /** + * プロジェクト基本情報を設定 + */ + setProjectInfo(key: keyof ProjectData, value: any): void { + (this.projectData as any)[key] = value; + } + + /** + * タスクを追加 + */ + addTask(task: Omit): string { + const taskId = `T${this.taskCounter.toString().padStart(3, '0')}`; + this.tasks.push({ + task_id: taskId, + ...task + }); + this.taskCounter++; + return taskId; + } + + /** + * タスクを更新 + */ + updateTask(taskId: string, updates: Partial): void { + const taskIndex = this.tasks.findIndex(t => t.task_id === taskId); + if (taskIndex !== -1) { + this.tasks[taskIndex] = { ...this.tasks[taskIndex], ...updates }; + } + } + + /** + * 現在のフェーズを取得 + */ + getCurrentPhase(): number { + return this.currentPhase; + } + + /** + * 次のフェーズに進む + */ + nextPhase(): void { + this.currentPhase++; + } + + /** + * JSONファイルを生成 + */ + generateJSON(): ProjectData { + return { + project_id: this.projectData.project_id || '', + project_name: this.projectData.project_name || '', + project_purpose: this.projectData.project_purpose || '', + project_type: this.projectData.project_type || '', + project_deadline: this.projectData.project_deadline || '', + github_url: this.projectData.github_url || '', + tasks: this.tasks + }; + } + + /** + * 対話履歴をMarkdown形式で生成 + */ + generateDialogueMarkdown(): string { + let markdown = `# プロジェクト作成対話履歴\n\n`; + markdown += `**プロジェクト名**: ${this.projectData.project_name || '未設定'}\n`; + markdown += `**プロジェクトID**: ${this.projectData.project_id || '未設定'}\n`; + markdown += `**作成日時**: ${new Date().toISOString()}\n\n`; + markdown += `---\n\n`; + + for (const entry of this.dialogueHistory) { + const speaker = entry.speaker === 'user' ? 'ユーザー' : 'アシスタント'; + const time = new Date(entry.timestamp).toLocaleString('ja-JP'); + markdown += `## ${speaker} (${time})\n\n`; + markdown += `${entry.message}\n\n`; + } + + return markdown; + } + + /** + * ファイルを保存 + * @param isUpdate - 既存プロジェクトの更新かどうか(デフォルト: false = 新規作成) + */ + async saveFiles(isUpdate: boolean = false): Promise { + try { + const projectId = this.projectData.project_id || 'unknown'; + const projectName = this.projectData.project_name || 'unknown'; + const prefix = isUpdate ? 'update_' : 'new_'; + const fileName = `${prefix}${projectId}_${projectName}`; + + // JSONファイル保存(outputs/ディレクトリに保存) + const outputsDir = path.join(process.cwd(), 'outputs'); + if (!fs.existsSync(outputsDir)) { + fs.mkdirSync(outputsDir, { recursive: true }); + } + const jsonData = this.generateJSON(); + const jsonPath = path.join(outputsDir, `${fileName}.json`); + fs.writeFileSync(jsonPath, JSON.stringify(jsonData, null, 2), 'utf-8'); + console.log(`✓ JSONファイル保存: ${jsonPath}`); + + // 対話履歴保存 + const markdown = this.generateDialogueMarkdown(); + const docsDir = path.join(process.cwd(), 'docs'); + if (!fs.existsSync(docsDir)) { + fs.mkdirSync(docsDir, { recursive: true }); + } + const mdPath = path.join(docsDir, `${fileName}.md`); + fs.writeFileSync(mdPath, markdown, 'utf-8'); + console.log(`✓ 対話履歴保存: ${mdPath}`); + + // Google Driveアップロード + await this.uploadToGoogleDrive(jsonPath); + + // スプレッドシート情報の表示 + const spreadsheetId = process.env.GOOGLE_SPREADSHEET_ID; + if (spreadsheetId) { + const spreadsheetUrl = `https://docs.google.com/spreadsheets/d/${spreadsheetId}`; + console.log(`\n📊 スプレッドシートURL: ${spreadsheetUrl}`); + console.log(` ※ GASトリガーでGanttチャートが自動生成されます`); + } + + // Discord通知(成功) + await this.sendDiscordNotification( + `プロジェクト「${projectName}」のJSONファイルが正常に生成されました。\n` + + `JSONファイル: \`outputs/${fileName}.json\`\n` + + `対話履歴: \`docs/${fileName}.md\`\n` + + (spreadsheetId ? `\nスプレッドシート: https://docs.google.com/spreadsheets/d/${spreadsheetId}` : ''), + false + ); + } catch (error) { + // Discord通知(エラー) + await this.sendDiscordNotification( + `プロジェクトファイルの保存中にエラーが発生しました。\n\n` + + `エラー内容: ${error instanceof Error ? error.message : String(error)}`, + true + ); + throw error; + } + } + + /** + * Google Driveにアップロード(重複チェック機能付き) + */ + private async uploadToGoogleDrive(filePath: string): Promise { + try { + const folderId = process.env.GOOGLE_DRIVE_FOLDER_ID; + if (!folderId) { + console.warn('⚠ GOOGLE_DRIVE_FOLDER_ID が設定されていません。アップロードをスキップします。'); + return; + } + + // アップロード履歴チェック + const fileName = path.basename(filePath); + const historyPath = path.join(process.cwd(), '.upload-history.json'); + let uploadHistory: Record = {}; + + if (fs.existsSync(historyPath)) { + uploadHistory = JSON.parse(fs.readFileSync(historyPath, 'utf-8')); + } + + if (uploadHistory[fileName]) { + console.log(`⏭ スキップ: ${fileName} は既にアップロード済みです`); + console.log(` ファイルID: ${uploadHistory[fileName]}`); + console.log(`\n⏰ GAS側で1分以内に自動的にGanttチャートが生成されます。`); + + const spreadsheetId = process.env.GOOGLE_SPREADSHEET_ID; + if (spreadsheetId) { + const spreadsheetUrl = `https://docs.google.com/spreadsheets/d/${spreadsheetId}`; + console.log(`📊 スプレッドシート: ${spreadsheetUrl}`); + } + return; + } + + // OAuth2認証設定(環境変数から読み込み想定) + const auth = new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + process.env.GOOGLE_REDIRECT_URI + ); + + // トークン設定(事前に取得したトークンを使用) + const tokenPath = path.join(process.cwd(), '.google-token.json'); + if (fs.existsSync(tokenPath)) { + const token = JSON.parse(fs.readFileSync(tokenPath, 'utf-8')); + auth.setCredentials(token); + } else { + console.warn('⚠ Google認証トークンが見つかりません。初回認証が必要です。'); + // TODO: 初回認証フローの実装 + return; + } + + const drive = google.drive({ version: 'v3', auth }); + + const fileMetadata = { + name: fileName, + parents: [folderId] + }; + + const media = { + mimeType: 'application/json', + body: fs.createReadStream(filePath) + }; + + const response = await drive.files.create({ + requestBody: fileMetadata, + media: media, + fields: 'id, name, webViewLink' + }); + + const fileId = response.data.id!; + console.log(`✓ Google Driveにアップロード完了`); + console.log(` ファイルID: ${fileId}`); + console.log(` URL: ${response.data.webViewLink}`); + console.log(`\n⏰ GAS側で1分以内に自動的にGanttチャートが生成されます。`); + + const spreadsheetId = process.env.GOOGLE_SPREADSHEET_ID; + if (spreadsheetId) { + const spreadsheetUrl = `https://docs.google.com/spreadsheets/d/${spreadsheetId}`; + console.log(`📊 スプレッドシート: ${spreadsheetUrl}`); + } + + // アップロード履歴を保存 + uploadHistory[fileName] = fileId; + fs.writeFileSync(historyPath, JSON.stringify(uploadHistory, null, 2), 'utf-8'); + + } catch (error) { + console.error('✗ Google Driveアップロードエラー:', error); + + // Discord通知(Google Driveエラー) + await this.sendDiscordNotification( + `Google Driveへのアップロード中にエラーが発生しました。\n\n` + + `エラー内容: ${error instanceof Error ? error.message : String(error)}\n\n` + + `ファイル: \`${path.basename(filePath)}\``, + true + ); + + throw error; + } + } + + /** + * 状態をJSON形式で取得(デバッグ用) + */ + getState(): object { + return { + currentPhase: this.currentPhase, + projectData: this.projectData, + tasksCount: this.tasks.length, + dialogueHistoryCount: this.dialogueHistory.length + }; + } + + /** + * Discord Webhookに通知を送信 + */ + private async sendDiscordNotification(message: string, isError: boolean = false): Promise { + const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + if (!webhookUrl) { + console.warn('⚠ DISCORD_WEBHOOK_URL が設定されていません。通知をスキップします。'); + return; + } + + try { + const url = new URL(webhookUrl); + const payload = JSON.stringify({ + embeds: [{ + title: isError ? '❌ Ganttチャート作成エラー' : '✅ Ganttチャート作成完了', + description: message, + color: isError ? 0xff0000 : 0x00ff00, + timestamp: new Date().toISOString(), + footer: { + text: 'Gantt Chart Generator' + }, + fields: [ + { + name: 'プロジェクトID', + value: this.projectData.project_id || '未設定', + inline: true + }, + { + name: 'プロジェクト名', + value: this.projectData.project_name || '未設定', + inline: true + }, + { + name: 'タスク数', + value: this.tasks.length.toString(), + inline: true + } + ] + }] + }); + + const options = { + hostname: url.hostname, + path: url.pathname + url.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload) + } + }; + + await new Promise((resolve, reject) => { + const req = https.request(options, (res) => { + if (res.statusCode === 204) { + console.log('✓ Discord通知送信完了'); + resolve(); + } else { + reject(new Error(`Discord通知失敗: ${res.statusCode}`)); + } + }); + + req.on('error', (error) => { + reject(error); + }); + + req.write(payload); + req.end(); + }); + } catch (error) { + console.error('✗ Discord通知送信エラー:', error); + // Discord通知の失敗は致命的エラーではないため、処理を続行 + } + } +} + +/** + * Google Drive OAuth2 初回認証 + */ +async function authenticateGoogleDrive(): Promise { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + const redirectUri = process.env.GOOGLE_REDIRECT_URI || 'http://localhost:19204/oauth2callback'; + + if (!clientId || !clientSecret) { + throw new Error('GOOGLE_CLIENT_ID と GOOGLE_CLIENT_SECRET を .env ファイルに設定してください。'); + } + + const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri); + + // 認証URLを生成 + const authUrl = oauth2Client.generateAuthUrl({ + access_type: 'offline', + prompt: 'consent', + scope: [ + 'https://www.googleapis.com/auth/drive.file' + ] + }); + + console.log('\n🔐 Google Drive OAuth2 認証を開始します\n'); + console.log('以下のURLをブラウザで開いてください:'); + console.log(`\n${authUrl}\n`); + + // ローカルサーバーを起動してコールバックを受け取る + return new Promise((resolve, reject) => { + const server = http.createServer(async (req, res) => { + try { + const reqUrl = url.parse(req.url || '', true); + + if (reqUrl.pathname === '/oauth2callback') { + const code = reqUrl.query.code as string; + + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end('

認証失敗

認証コードが取得できませんでした。

'); + reject(new Error('認証コードが取得できませんでした')); + server.close(); + return; + } + + // トークン取得 + const { tokens } = await oauth2Client.getToken(code); + oauth2Client.setCredentials(tokens); + + // トークンをファイルに保存 + const tokenPath = path.join(process.cwd(), '.google-token.json'); + fs.writeFileSync(tokenPath, JSON.stringify(tokens, null, 2), 'utf-8'); + + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(` + + 認証成功 + +

✅ 認証成功!

+

Google Drive との連携が完了しました。

+

このウィンドウを閉じてターミナルに戻ってください。

+ + + `); + + console.log('\n✓ Google Drive 認証完了'); + console.log(`✓ トークン保存: ${tokenPath}`); + console.log('\nこれで Google Drive へのアップロードが可能になりました。'); + + server.close(); + resolve(); + } + } catch (error) { + res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end('

エラー

認証処理中にエラーが発生しました。

'); + reject(error); + server.close(); + } + }); + + server.listen(19204, () => { + console.log('ローカル認証サーバーを起動しました (http://localhost:19204)'); + console.log('ブラウザでログイン後、自動的にトークンが保存されます...\n'); + }); + + server.on('error', (error) => { + reject(error); + }); + }); +} + +/** + * コマンドライン引数パーサー + */ +function parseArgs(): { command: string; isUpdate: boolean } { + const args = process.argv.slice(2); + const command = args[0] || 'help'; + const isUpdate = args.includes('--update'); + + return { command, isUpdate }; +} + +/** + * メイン処理 + */ +async function main() { + const { command, isUpdate } = parseArgs(); + + const manager = new GanttDialogueManager(); + + switch (command) { + case 'init': + console.log('Ganttチャート作成を開始します...'); + console.log('対話を進めてください。完了後に `save` コマンドを実行してください。'); + break; + + case 'save': + if (isUpdate) { + console.log('既存プロジェクトを更新しています...'); + } else { + console.log('新規プロジェクトを作成しています...'); + } + await manager.saveFiles(isUpdate); + console.log('✓ すべてのファイルが保存されました'); + break; + + case 'auth': + console.log('Google Drive OAuth2 認証を開始します...'); + await authenticateGoogleDrive(); + break; + + case 'status': + console.log('現在の状態:'); + console.log(JSON.stringify(manager.getState(), null, 2)); + break; + + case 'help': + default: + console.log(` +Ganttチャート自動生成ヘルパー + +使い方: + npm run gantt: [options] + +コマンド: + init - 新規プロジェクト作成開始 + save - JSONと対話履歴を保存(自動的にGoogle Driveにアップロード&Ganttチャート生成) + オプション: + --update 既存プロジェクトの更新として保存(update_ プレフィックス付き) + 省略時 新規プロジェクトとして保存(new_ プレフィックス付き) + auth - Google Drive OAuth2 初回認証 + status - 現在の状態を表示 + help - このヘルプを表示 + +使用例: + npm run gantt:save # 新規プロジェクト作成(new_192_プロジェクト.json) + npm run gantt:save -- --update # 既存プロジェクト更新(update_192_プロジェクト.json) + +初回セットアップ手順: + 1. .env ファイルに以下を設定: + GOOGLE_CLIENT_ID=your_client_id + GOOGLE_CLIENT_SECRET=your_client_secret + GOOGLE_REDIRECT_URI=http://localhost:19204/oauth2callback + GOOGLE_DRIVE_FOLDER_ID=your_folder_id + GOOGLE_SCRIPT_ID=your_script_id (GASのスクリプトID) + GOOGLE_SPREADSHEET_ID=your_spreadsheet_id + + 2. npm run gantt:auth を実行してGoogle認証 + + 3. /gantt コマンドでプロジェクト作成 + + 4. npm run gantt:save で保存&アップロード + → 自動的にGanttチャートが生成されます + `); + break; + } +} + +// スクリプト実行 +if (require.main === module) { + main().catch(console.error); +} + +export { GanttDialogueManager, ProjectData, Task, DialogueEntry }; diff --git a/src/update-tags.ts b/src/update-tags.ts new file mode 100644 index 0000000..b02db0f --- /dev/null +++ b/src/update-tags.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env ts-node + +/** + * JSONファイルのタグを最新の定義に更新するスクリプト + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +// タグのマッピング定義 +const TAG_MAPPING: Record = { + '企画': '企画・計画', + '設計': '設計', + '開発': '開発・実装', + 'デザイン': '設計', // UIデザインも設計カテゴリに含める + 'テスト': 'テスト・検証', + 'レビュー': 'テスト・検証', // レビューもテスト・検証に含める + '資料作成': '事務・管理', + '調整': '事務・管理', + '運用': '運用・保守', + 'その他': 'その他' +}; + +/** + * タグを更新する + */ +function updateTags(filePath: string): void { + try { + // JSONファイルを読み込み + const jsonData = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + + let updateCount = 0; + + // 各タスクのタグを更新 + for (const task of jsonData.tasks) { + if (task.tags && Array.isArray(task.tags)) { + const updatedTags = task.tags.map((tag: string) => { + const newTag = TAG_MAPPING[tag] || tag; + if (newTag !== tag) { + updateCount++; + } + return newTag; + }); + + // 重複を削除 + task.tags = [...new Set(updatedTags)]; + } + } + + // 更新したJSONを保存 + fs.writeFileSync(filePath, JSON.stringify(jsonData, null, 2), 'utf-8'); + + console.log(`✓ タグ更新完了: ${filePath}`); + console.log(` 更新されたタグ数: ${updateCount}`); + } catch (error) { + console.error('✗ エラー:', error); + throw error; + } +} + +/** + * メイン処理 + */ +function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.error('使い方: ts-node src/update-tags.ts <ファイルパス>'); + console.error('例: ts-node src/update-tags.ts 0027_お助けマンサービスHPの開発.json'); + process.exit(1); + } + + const filePath = args[0]; + + // 絶対パスでない場合、カレントディレクトリからの相対パスとして扱う + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.join(process.cwd(), filePath); + + if (!fs.existsSync(absolutePath)) { + console.error(`✗ ファイルが見つかりません: ${absolutePath}`); + process.exit(1); + } + + updateTags(absolutePath); +} + +// スクリプト実行 +if (require.main === module) { + main(); +} + +export { updateTags, TAG_MAPPING }; diff --git a/src/upload-to-drive.ts b/src/upload-to-drive.ts new file mode 100644 index 0000000..e9441ed --- /dev/null +++ b/src/upload-to-drive.ts @@ -0,0 +1,143 @@ +#!/usr/bin/env ts-node + +/** + * 既存のJSONファイルをGoogle Driveにアップロードするスクリプト + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { google } from 'googleapis'; +import * as dotenv from 'dotenv'; + +// 環境変数読み込み +dotenv.config(); + +/** + * Google Driveにアップロード + */ +async function uploadToGoogleDrive(filePath: string): Promise { + try { + const folderId = process.env.GOOGLE_DRIVE_FOLDER_ID; + if (!folderId) { + throw new Error('GOOGLE_DRIVE_FOLDER_ID が設定されていません。'); + } + + // OAuth2認証設定 + const auth = new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + process.env.GOOGLE_REDIRECT_URI + ); + + // トークン設定 + const tokenPath = path.join(process.cwd(), '.google-token.json'); + if (!fs.existsSync(tokenPath)) { + throw new Error('Google認証トークンが見つかりません。先に npm run gantt:auth を実行してください。'); + } + + const token = JSON.parse(fs.readFileSync(tokenPath, 'utf-8')); + auth.setCredentials(token); + + const drive = google.drive({ version: 'v3', auth }); + + // ファイル名取得 + const fileName = path.basename(filePath); + + // アップロード履歴チェック + const historyPath = path.join(process.cwd(), '.upload-history.json'); + let uploadHistory: Record = {}; + + if (fs.existsSync(historyPath)) { + uploadHistory = JSON.parse(fs.readFileSync(historyPath, 'utf-8')); + } + + if (uploadHistory[fileName]) { + console.log(`⏭ スキップ: ${fileName} は既にアップロード済みです`); + console.log(` ファイルID: ${uploadHistory[fileName]}`); + + const spreadsheetId = process.env.GOOGLE_SPREADSHEET_ID; + if (spreadsheetId) { + const spreadsheetUrl = `https://docs.google.com/spreadsheets/d/${spreadsheetId}`; + console.log(`\n📊 スプレッドシート: ${spreadsheetUrl}`); + } + return; + } + + // ファイルメタデータ + const fileMetadata = { + name: fileName, + parents: [folderId] + }; + + const media = { + mimeType: 'application/json', + body: fs.createReadStream(filePath) + }; + + console.log(`📤 アップロード中: ${fileName}`); + + // アップロード実行 + const response = await drive.files.create({ + requestBody: fileMetadata, + media: media, + fields: 'id, name, webViewLink' + }); + + const fileId = response.data.id!; + console.log(`✓ Google Driveにアップロード完了`); + console.log(` ファイルID: ${fileId}`); + console.log(` URL: ${response.data.webViewLink}`); + console.log(`\n⏰ GAS側で1分以内に自動的にGanttチャートが生成されます。`); + + const spreadsheetId = process.env.GOOGLE_SPREADSHEET_ID; + if (spreadsheetId) { + const spreadsheetUrl = `https://docs.google.com/spreadsheets/d/${spreadsheetId}`; + console.log(`📊 スプレッドシート: ${spreadsheetUrl}`); + } + + // アップロード履歴を保存 + uploadHistory[fileName] = fileId; + fs.writeFileSync(historyPath, JSON.stringify(uploadHistory, null, 2), 'utf-8'); + + } catch (error) { + console.error('✗ Google Driveアップロードエラー:', error); + throw error; + } +} + +/** + * メイン処理 + */ +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.error('使い方: ts-node src/upload-to-drive.ts <ファイルパス>'); + console.error('例: ts-node src/upload-to-drive.ts 0027_お助けマンサービスHPの開発.json'); + process.exit(1); + } + + const filePath = args[0]; + + // 絶対パスでない場合、カレントディレクトリからの相対パスとして扱う + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.join(process.cwd(), filePath); + + if (!fs.existsSync(absolutePath)) { + console.error(`✗ ファイルが見つかりません: ${absolutePath}`); + process.exit(1); + } + + await uploadToGoogleDrive(absolutePath); +} + +// スクリプト実行 +if (require.main === module) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} + +export { uploadToGoogleDrive }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c71688c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}