運営: 進捗管理ダッシュボード配布版を更新

Made-with: Cursor
This commit is contained in:
ADS Admin 2026-04-03 19:28:30 +09:00
commit 30afda7196
18 changed files with 4391 additions and 0 deletions

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
# === 必須 ===
MAGAZINE_LINEAR_API_KEY=lin_api_xxxxx
MAGAZINE_LINEAR_TEAM_KEY=YOUR_TEAM
MAGAZINE_GEMINI_API_KEY=xxxxx
MAGAZINE_SLACK_BOT_TOKEN=xoxb-xxxxx
MAGAZINE_SLACK_CHANNEL_ID=C0XXXXXXXXX
MAGAZINE_SURGE_LOGIN=your-email@example.com
MAGAZINE_SURGE_TOKEN=xxxxx
MAGAZINE_SURGE_DOMAIN=your-project-dashboard.surge.sh
# === オプション ===
# MAGAZINE_GEMINI_MODEL=models/gemini-2.5-flash
# MAGAZINE_SURGE_CDN_WAIT_MS=10000

43
.github/workflows/run-dashboard.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: ダッシュボード更新
on:
workflow_dispatch:
# schedule:
# - cron: '0 0 * * 1-5' # 毎朝9時JST = UTC+9、平日のみ
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Playwright ブラウザキャッシュ
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
- run: npx playwright install-deps chromium
if: steps.playwright-cache.outputs.cache-hit == 'true'
- run: npm run run-all
env:
MAGAZINE_LINEAR_API_KEY: ${{ secrets.MAGAZINE_LINEAR_API_KEY }}
MAGAZINE_LINEAR_TEAM_KEY: ${{ secrets.MAGAZINE_LINEAR_TEAM_KEY }}
MAGAZINE_GEMINI_API_KEY: ${{ secrets.MAGAZINE_GEMINI_API_KEY }}
MAGAZINE_SLACK_BOT_TOKEN: ${{ secrets.MAGAZINE_SLACK_BOT_TOKEN }}
MAGAZINE_SLACK_CHANNEL_ID: ${{ secrets.MAGAZINE_SLACK_CHANNEL_ID }}
MAGAZINE_SURGE_LOGIN: ${{ secrets.MAGAZINE_SURGE_LOGIN }}
MAGAZINE_SURGE_TOKEN: ${{ secrets.MAGAZINE_SURGE_TOKEN }}
MAGAZINE_SURGE_DOMAIN: ${{ secrets.MAGAZINE_SURGE_DOMAIN }}

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
node_modules/
.env
.env.local
data/linear-data.json
data/health-data.json
data/ai-suggestions.json
data/deployed-url.txt
data/stock-tracker.json
output/dashboard.html
output/screenshot*.png
dist/
npm-debug.log*
.DS_Store

397
README.md Normal file
View File

@ -0,0 +1,397 @@
# 進捗管理ダッシュボード
タスク管理ツールLinearのデータを自動で取得し、AIが健康度を判定、HTMLダッシュボードを生成してSlackに画像付きで通知するツールです。
```
┌──────────────────────────────────────────────────────────────┐
│ 📊 マガジン進捗管理ダッシュボード │
│ │
│ 💊 進捗健康度 │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ 企画案 │ │ 構成 │ │ 原稿 │ │ 動画 │ │
│ │ 🟢 │ │ 🟡 │ │ 🟢 │ │ 🔴 │ │
│ │ 順調 │ │ 注意 │ │ 順調 │ │ 危険 │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
│ │
│ 🤖 AIからの提案 │
│ 🔥 優先度:高 ─ 動画編集が3日遅延... │
│ 🧭 優先度:中 ─ 原稿の引き継ぎを... │
│ 🌱 優先度:低 ─ ナレッジを蓄積... │
│ │
│ 📋 マガジン別ステータス / 📅 カレンダー │
└──────────────────────────────────────────────────────────────┘
```
Slackにはこのダッシュボードのスクリーンショットが3枚届きます。ブラウザで詳細も確認できます。
一人でも使えます。自分のLinearにタスクを登録すればダッシュボードが動きます。チームメンバーがいなくても問題ありません。
---
## 料金について
このツールで使う外部サービスはすべて無料枠で利用できます。
| サービス | 用途 | 料金 |
|---|---|---|
| Linear | タスク管理(データの取得元) | Free プランで十分 |
| Gemini API | AIによる改善提案の生成 | 無料枠あり |
| Surge | ダッシュボードHTMLの公開 | 無料 |
| Slack | ダッシュボード画像の通知先 | 無料ワークスペースで可 |
| GitHub Actions | 定期自動実行(オプション) | 毎月 2,000 分無料 |
---
## このツールの4パーツ
第3回講義で学んだ「4パーツ」が、このツールではどのファイルに対応しているかを示します。
```
┌─────────────┐
│ トリガー │ .github/workflows/run-dashboard.yml
│ (いつ動く) │ → 毎朝9時 or 手動実行
└──────┬──────┘
┌─────────────┐
│ ソース元 │ scripts/fetch-data.js
│ (データ) │ → Linear API からタスクデータを取得
└──────┬──────┘
┌─────────────┐ scripts/calculate-health.js ← 健康度を計算
│ 処理する場所 │ scripts/generate-ai-suggestions.js ← AIが提案を生成
│ (加工) │ scripts/generate-dashboard.js ← HTMLを組み立てる
└──────┬──────┘
┌─────────────┐ scripts/deploy-to-surge.js → ウェブに公開
│ 届ける先 │ scripts/take-screenshot.js → 画像に変換
│ (配信) │ scripts/post-to-slack.js → Slack に通知
└─────────────┘
```
---
## セットアップ
### 準備するもの
- Node.js 18 以上(持っていない場合は AI に「Node.jsをインストールして」と依頼
- Linear アカウント(持っていない場合は Part B で作成します)
- Google アカウント(持っていない場合は Part C の前に作成してください)
- Slack ワークスペース(持っていない場合は Part D で作成します)
### Part A: リポジトリを開く
1. Giteaからリポジトリをクローンする
2. Cursorでフォルダを開く
3. ターミナルを開いて依存関係をインストールする
ターミナルの開き方: Cursor/VSCode のメニュー「Terminal」→「New Terminal」、またはキーボードで `` Ctrl + ` ``(バッククォート)を押す。
```bash
npm install
```
### Part B: Linear API キーを取得する
Linear はタスク管理ツールです。このツールのデータ取得元になります。
1. [Linear](https://linear.app/) にアカウントを作成する(または既存のワークスペースを使う)
アカウント作成中に GitHub 連携・メンバー招待・メール通知の設定画面が表示されますが、**すべてスキップ「I'll do this later」して問題ありません**。後からいつでも設定できます。
2. Settings → 左メニューの「Account」→「Security & Access」→ Personal API keys で新しいキーを作成する
> 「Settings → API」で見つからない場合は、画面左メニューの「Account」セクションの下にある「Security & Access」を探してください。
**APIキーは作成時の一度だけ表示されます。** コピーし忘れて画面を閉じてしまった場合は、古いキーを削除して新しく作り直せば大丈夫です。
3. `.env` ファイルを作成し、キーを設定する
```bash
cp .env.example .env
```
`.env` を開いて以下を設定:
```
MAGAZINE_LINEAR_API_KEY=lin_api_ここにコピーしたキーを貼る
MAGAZINE_LINEAR_TEAM_KEY=あなたのチームキー
```
`MAGAZINE_LINEAR_TEAM_KEY` は Linear のチーム設定画面の URL に含まれるキー(例: `MYTEAM`)です。
**チェックポイント**: 以下のコマンドでデータが取得できることを確認:
```bash
node scripts/fetch-data.js
```
`data/linear-data.json` にデータが保存されれば成功です。
:::info Linear のラベル設定
このツールは Linear のラベルグループを使って進捗を判定します。初期設定では「マガジン作成ステータス(イシュー)」というラベルグループを参照します。自分のプロジェクトに合わせて `config/settings.js` のラベル名を変更してください。
:::
### Part C: Gemini API キーを取得する
Gemini は Google の AI です。健康度データから改善提案を自動生成します。APIキーの発行は [Google AI Studio](https://aistudio.google.com/) という管理画面で行いますGemini を直接使うのではなく、API キーだけをここで取得します)。
Google アカウントが必要です。持っていない場合は先に作成してください。
1. [Google AI Studio](https://aistudio.google.com/) にアクセスする
2. 「Get API key」→「Create API key」でキーを作成する既にキーがある場合はそれを使っても OK
3. `.env` に追記する
```
MAGAZINE_GEMINI_API_KEY=ここにコピーしたキーを貼る
```
**チェックポイント**: 以下のコマンドで提案が生成されることを確認:
```bash
npm run calculate-health
npm run ai-suggestions
```
「生成されたAI提案」が3件表示されれば成功です。Gemini API が使えない場合も、フォールバック機能で提案は自動生成されます。
### Part D: Slack Bot を作成する
Slack にダッシュボードの画像を投稿するための Bot を作ります。
Slack アカウントとワークスペースを持っていない場合は、先に [Slack](https://slack.com/) でワークスペースを作成してください。
1. [Slack API](https://api.slack.com/apps) にアクセスし「Create New App」→「From scratch」
2. 左メニューの「OAuth & Permissions」を開き、「Scopes」セクションまでスクロールする
3. 「Bot Token Scopes」の「Add an OAuth Scope」ボタンをクリックし、検索ボックスに入力して以下の2つを追加する:
- `chat:write`
- `files:write`
4. ページ上部の「Install to Workspace」または左メニューの「Install App」をクリックしてインストールする
5. インストール後に表示される「Bot User OAuth Token」`xoxb-` で始まる文字列)をコピーする
> トークンが見つからない場合は、左メニューの「OAuth & Permissions」を開くと「OAuth Tokens for Your Workspace」セクションに表示されています。
6. 投稿先チャンネルで `/invite @あなたのBot名` を実行
7. チャンネル ID を取得(チャンネル名を右クリック → 「チャンネル詳細を表示」の最下部)
8. `.env` に追記する
```
MAGAZINE_SLACK_BOT_TOKEN=xoxb-ここにトークンを貼る
MAGAZINE_SLACK_CHANNEL_ID=C0ここにチャンネルIDを貼る
```
### Part E: Surge アカウントを準備する
Surge はHTMLを無料で公開できるサービスです。ダッシュボードをウェブで見られるようにします。
1. 以下のコマンドでアカウントを作成する
```bash
npx surge login
```
メールアドレスとパスワードの入力を求められます。**初回はアカウントが自動作成されます。** 既存のアカウントにログインするのではなく、好きなパスワードを新しく設定してください。
> **ターミナルでのパスワード入力について**: パスワードを入力しても画面には何も表示されません(`****` も出ません。これはセキュリティ上の仕様です。そのまま入力してEnterを押せば反映されます。
2. トークンを取得する
```bash
npx surge token
```
3. `.env` に追記する
```
MAGAZINE_SURGE_LOGIN=あなたのメールアドレス
MAGAZINE_SURGE_TOKEN=ここにトークンを貼る
MAGAZINE_SURGE_DOMAIN=あなたのプロジェクト名-dashboard.surge.sh
```
`MAGAZINE_SURGE_DOMAIN` は好きな名前を設定できます(例: `my-team-dashboard.surge.sh`)。
### Part F: 動作確認
すべての環境変数が設定できたら、動作確認に進みます。
まず、スクリーンショット撮影に必要な Playwright のブラウザをインストールします:
```bash
npx playwright install chromium
```
次に、全パイプラインを実行します:
```bash
npm run run-all
```
成功すると:
- `output/dashboard.html` にダッシュボードが生成される
- Surge にデプロイされてブラウザで見られる
- Slack にスクリーンショット3枚が投稿される
ここまでで手動実行は完了です。毎朝自動でダッシュボードを更新してSlackに届くようにしたい場合は、次の「自動実行の設定GitHub Actions」に進んでください。
---
## 自動実行の設定GitHub Actions
GitHub Actions を使うと、毎朝自動でダッシュボードを更新してSlackに投稿できます。手動で毎朝コマンドを打つ必要がなくなります。
GitHub アカウントを持っていない場合は、先に [GitHub](https://github.com/) でアカウントを作成してください。
### 設定手順
1. GitHub にリポジトリを作成し、コードをプッシュする
Gitea からクローンした場合、GitHub は別のリモートとして追加します。AIに「このリポジトリを GitHub にもプッシュしたい」と相談すれば手順を案内してもらえます。
> **`npm ci` のエラーについて**: GitHub Actions のワークフローは `npm ci` を使って依存関係をインストールします。`package-lock.json` がコミットされていないと失敗するので、プッシュ前に `package-lock.json` が含まれていることを確認してください。
2. Settings → Secrets and variables → Actions で以下のシークレットを追加:
| シークレット名 | 値 |
|---|---|
| `MAGAZINE_LINEAR_API_KEY` | Linear API キー |
| `MAGAZINE_LINEAR_TEAM_KEY` | Linear チームキー |
| `MAGAZINE_GEMINI_API_KEY` | Gemini API キー |
| `MAGAZINE_SLACK_BOT_TOKEN` | Slack Bot トークン |
| `MAGAZINE_SLACK_CHANNEL_ID` | Slack チャンネル ID |
| `MAGAZINE_SURGE_LOGIN` | Surge メールアドレス |
| `MAGAZINE_SURGE_TOKEN` | Surge トークン |
| `MAGAZINE_SURGE_DOMAIN` | Surge ドメイン |
3. `.github/workflows/run-dashboard.yml``schedule` 行のコメントを外す
```yaml
schedule:
- cron: '0 0 * * 1-5' # 毎朝9時JST、平日のみ
```
4. Actions タブで「ダッシュボード更新」を手動実行して動作を確認する
:::info 企画案ストック判定の制限
GitHub Actions では実行ごとにワークスペースがリセットされるため、企画案ストックの週次判定は正確に機能しません(毎回初回扱いになります)。正確な判定が必要な場合は、`data/stock-tracker.json` を永続化する設計を追加してください。
:::
---
## カスタマイズの手順
### Linear のラベル構造を変更する
`config/settings.js` を開いて、あなたの Linear ワークスペースのラベル名に合わせて変更してください。
```javascript
export const LABEL_GROUPS = {
parentStatus: 'あなたのラベルグループ名',
subIssueStatus: 'あなたのサブイシューラベルグループ名',
};
export const STATUS_LABELS = {
stock: '1.企画案ストック', // あなたのラベル名に変更
composition: '2.構成作成中', // あなたのラベル名に変更
manuscript: '3.原稿執筆中', // あなたのラベル名に変更
video: '4.動画編集中', // あなたのラベル名に変更
};
```
### 担当者名を変更する
`config/mappings.js``ASSIGNEE_MAPPINGS_INCLUDE``ASSIGNEE_MAPPINGS_STARTS_WITH` を編集して、チームメンバーの Linear ユーザー名と表示名の対応を設定してください。
### 健康度の閾値を調整する
`config/health-thresholds.yaml` を編集します。コードを変更する必要はありません。
```yaml
stock:
healthy: 8 # ≥8本で順調🟢
warning: 5 # 5-7本で注意🟡
delay:
healthy: 0 # 遅延なしで順調
warning: 1 # 1日遅延で注意
```
### AI提案のプロンプトを調整する
`config/ai-prompts/unified.md` を編集します。`{{CONTEXT}}` の部分に健康度データが自動で挿入されます。
### 見た目CSSを変更する
`config/dashboard-styles.css` を編集します。ダッシュボードのデザインを自由に変更できます。
---
## 仕組みの解説
このツールは7つのスクリプトがパイプラインとして順番に実行されます。
```
npm run run-all
① fetch-data Linear API からタスクデータを取得 → data/linear-data.json
② calculate-health 健康度を計算 → data/health-data.json
③ ai-suggestions Gemini で改善提案を生成 → data/ai-suggestions.json
④ generate-dashboard HTML ダッシュボードを組み立て → output/dashboard.html
⑤ deploy Surge にデプロイ → data/deployed-url.txt
⑥ screenshot Playwright でスクリーンショット → output/screenshot-*.png
⑦ post-slack Slack に画像とサマリーを投稿
```
各スクリプトは独立しており、個別に実行できます:
```bash
npm run fetch-data # ①だけ実行
npm run calculate-health # ②だけ実行
npm run ai-suggestions # ③だけ実行
npm run generate-dashboard # ④だけ実行
npm run deploy # ⑤だけ実行
npm run screenshot # ⑥だけ実行
npm run post-slack # ⑦だけ実行
```
### 設定ファイルの役割
| ファイル | 役割 |
|---|---|
| `config/settings.js` | Linear のチームキー・ラベル名など、プロジェクト固有の設定 |
| `config/mappings.js` | 担当者名の変換ルール |
| `config/health-thresholds.yaml` | 健康度判定の閾値(コード変更不要で調整可能) |
| `config/ai-prompts/unified.md` | AI提案生成のプロンプト |
| `config/dashboard-styles.css` | ダッシュボードの見た目 |
---
## セキュリティについて
- `.env` ファイルには API キーやトークンが含まれます。**Git にコミットしてはいけません**
- `.gitignore``.env` は除外済みです
- `.env.example` はキーの形式だけを示したテンプレートで、実際のキーは含まれていません
**⚠️ API キーやパスワードを AI チャットに貼り付けないでください。** Cursor のチャットやその他の AI ツールに API キーを送ると、外部サーバーに送信される可能性があります。キーの設定は `.env` ファイルへの直接入力で行い、チャット欄には貼らないようにしてください。
---
## 困ったときは
つまずいたときは、以下の順で相談してください。
1. **AIに聞く** — エラー文やスクリーンショットを貼って質問する
2. **Slackのチームホームチャンネルで相談する** — 「AIにこう聞いたけど解決しなかった」と経緯を添える
3. [**問い合わせフォーム**](https://tayori.com/form/59d283f664136f1bf4525b0a7eef7d3814bcdd72)**から運営に連絡する** — 試したことを一緒に書く
### よくあるトラブル
| 症状 | 対処 |
|---|---|
| `fetch-data.js` でデータが0件 | Linear のラベルグループ名が `settings.js` と一致しているか確認 |
| Gemini API エラー | フォールバック機能が自動で動くので、提案は生成されます |
| Slack `not_in_channel` | 投稿先チャンネルで `/invite @あなたのBot名` を実行 |
| Surge デプロイ失敗 | `npx surge login` でログイン状態を確認 |
| スクリーンショットが真っ白 | `npx playwright install chromium` を実行 |
:::info 環境制約がある場合
会社のSlackにBot追加不可、APIキーが取れないなどの制約がある場合は、ソース元をJSONファイルに変えてローカルで動かすこともできます。AIに「Linear API を使わずにサンプルデータで動かしたい」と相談してみてください。
:::

View File

@ -0,0 +1,142 @@
# AI提案プロンプト統合版
あなたはマガジン制作プロジェクトマネージャーのアシスタントです。以下の健康度データに基づいて、具体的なアクションを提案してください。
## 現在の状況
{{CONTEXT}}
## 指示
現在の状況を踏まえて**最も重要な3つの提案を生成**し、それぞれに適切な優先度(高・中・低)を割り振ってください。
**重要**: 同じ優先度が複数あっても構いません。状況に応じて柔軟に判断してください。
- 緊急課題が3つある場合 → 「高・高・高」でもOK
- 緊急課題が2つある場合 → 「高・高・中」でもOK
- 緊急課題がない場合 → 「中・中・低」や「低・低・低」でもOK
### 優先度の基準
#### 優先度:高(今日・明日中に対応すべき緊急課題)
- **対象**: 2日以上遅延しているマガジン、期限未設定の重要タスク、緊急対応が必要な状況
- **アクション**: 今日・明日中に実行すべき具体的な次のステップ3つ以内
- **ポイント**: 放置すると大きな影響が出るもの
#### 優先度:中(今週中に対応すべき改善課題)
- **対象**: 2日以内の軽微な遅延、リソース配分の偏り、ワークフロー改善の機会
- **アクション**: 今週中に実行すべき具体的な次のステップ3つ以内
- **ポイント**: 早めに対処すれば悪化を防げるもの
#### 優先度:低(長期的な改善提案)
- **対象**: 現状は順調だが、さらに効率化・品質向上できる領域
- **アクション**: 長期的な改善につながる具体的な次のステップ3つ以内
- **ポイント**: 今すぐでなくても良いが、やればプラスになるもの
## 出力形式
以下のJSON配列形式で、**必ず3つの提案**を出力してください:
```json
[
{
"priority": "high",
"priorityLabel": "優先度:高",
"problem": "問題の説明1-2文",
"action": "• アクション1\n• アクション2\n• アクション3"
},
{
"priority": "medium",
"priorityLabel": "優先度:中",
"problem": "問題の説明1-2文",
"action": "• アクション1\n• アクション2\n• アクション3"
},
{
"priority": "low",
"priorityLabel": "優先度:低",
"problem": "改善機会の説明1-2文",
"action": "• アクション1\n• アクション2\n• アクション3"
}
]
```
## 重要な注意事項
1. **必ず3つの提案を生成**してください
2. **状況に応じて優先度を柔軟に割り振る**:同じ優先度が複数あっても構いません
3. **アクションは具体的に**:「検討する」ではなく「〇〇を実施し、△△を確認する」のように具体的に
4. **JSON形式を厳守**余計なテキストを含めず、純粋なJSON配列のみを出力
## 例1: 緊急課題が複数ある場合(高・高・中)
```json
[
{
"priority": "high",
"priorityLabel": "優先度:高",
"problem": "「AIエージェント入門」が期限を5日超過し、原稿執筆中のまま停滞しています。",
"action": "• 担当者に即日ヒアリングし、ブロッカーを特定\n• 必要に応じてライター追加またはスコープ縮小を検討\n• 48時間以内に初稿完了の再計画を立てる"
},
{
"priority": "high",
"priorityLabel": "優先度:高",
"problem": "原稿期限が未設定のマガジンが2件あり、進捗管理が不可能な状態です。",
"action": "• 本日中に担当者2名に連絡し、進捗と完了見込みをヒアリング\n• ヒアリング結果に基づき、明日中に両マガジンの期限を設定\n• 期限設定ルールを再通知し、今後の未設定を防ぐ"
},
{
"priority": "medium",
"priorityLabel": "優先度:中",
"problem": "原稿執筆中のマガジンが7本に対し、動画編集中は2本のみで、リソース配分に偏りがあります。",
"action": "• 原稿完了が近いマガジン3本を特定し、動画チームに事前共有\n• 動画編集のボトルネックを洗い出し、外注可否を検討\n• 来週の原稿→動画の引き継ぎ計画を明確化"
}
]
```
## 例2: 非常事態(高・高・高)
```json
[
{
"priority": "high",
"priorityLabel": "優先度:高",
"problem": "3件のマガジンが期限を3日以上超過し、公開スケジュールに深刻な影響が出ています。",
"action": "• 本日中に緊急ミーティングを開催し、全体状況を共有\n• 各マガジンの完了見込みを明確化し、公開スケジュールを再調整\n• 必要に応じてリソースを一時的に集中投入"
},
{
"priority": "high",
"priorityLabel": "優先度:高",
"problem": "企画ストックが2本まで減少し、2週間後にコンテンツ供給が途絶える危機的状況です。",
"action": "• 今日明日で緊急企画会議を開催し、最低5件の企画を立案\n• 過去の人気コンテンツのリメイク企画を3件追加\n• 外部ライターに緊急依頼し、企画を補充"
},
{
"priority": "high",
"priorityLabel": "優先度:高",
"problem": "5件のマガジンで期限が未設定のまま放置され、全体の進捗把握が不可能になっています。",
"action": "• 本日中に全担当者へ緊急連絡し、各マガジンの状況をヒアリング\n• 明日までに全マガジンの期限を設定し、進捗管理を再開\n• 期限設定を必須とする運用ルールを策定し、全員に徹底"
}
]
```
## 例3: 全体が順調な場合(中・低・低)
```json
[
{
"priority": "medium",
"priorityLabel": "優先度:中",
"problem": "現在は順調ですが、期限未設定のサブイシューが3件あり、今後の遅延リスクがあります。",
"action": "• 期限未設定の3件のサブイシューに期限を設定\n• 各担当者と進捗確認ミーティングを今週中に実施\n• 期限設定ルールを明文化し、チームで共有"
},
{
"priority": "low",
"priorityLabel": "優先度:低",
"problem": "全体的に順調ですが、動画編集の完了ペースが原稿執筆に比べてやや遅い傾向があります。",
"action": "• 動画編集の平均所要日数を計測し、標準化を検討\n• 編集テンプレートやガイドラインを整備し、効率化\n• 外注パートナーの追加を検討し、繁忙期に備える"
},
{
"priority": "low",
"priorityLabel": "優先度:低",
"problem": "企画ストックは十分ですが、トピックの多様性を高めることで読者満足度をさらに向上できます。",
"action": "• 過去6ヶ月のマガジンテーマを分析し、カテゴリの偏りを確認\n• 新規カテゴリ候補を3つリストアップし、企画会議で議論\n• 読者アンケートを実施し、需要の高いテーマを特定"
}
]
```

792
config/dashboard-styles.css Normal file
View File

@ -0,0 +1,792 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(20deg, #D60C52 0%, #24609E 100%);
padding: 20px;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
}
.container {
width: 1200px;
max-width: 95vw;
background:
repeating-linear-gradient(
20deg,
transparent,
transparent 10px,
rgba(214, 12, 82, 0.015) 10px,
rgba(214, 12, 82, 0.015) 11px,
transparent 11px,
transparent 21px,
rgba(36, 96, 158, 0.015) 21px,
rgba(36, 96, 158, 0.015) 22px
),
rgba(255, 255, 255, 0.95);
border-radius: 24px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
h1 {
color: #333;
font-size: 2.5em;
margin-bottom: 10px;
text-align: center;
}
.subtitle {
text-align: center;
color: #666;
margin-bottom: 40px;
font-size: 1em;
}
/* 進捗健康度(最上部) */
.health-status {
margin-bottom: 40px;
position: relative;
}
.health-status::after {
content: '';
display: block;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(214, 12, 82, 0.35), rgba(36, 96, 158, 0.35), transparent);
margin-top: 40px;
border-radius: 2px;
}
.health-status h2 {
color: #333;
font-size: 1.8em;
margin-bottom: 20px;
text-align: center;
position: relative;
text-shadow: 0 2px 12px rgba(214, 12, 82, 0.12);
}
/* 全体カード */
.health-overall {
background: white;
border-radius: 20px;
padding: 30px;
box-shadow: 0 8px 24px rgba(214, 12, 82, 0.15);
text-align: center;
margin-bottom: 20px;
border: 3px solid transparent;
background-image:
linear-gradient(white, white),
linear-gradient(20deg, #D60C52, #24609E);
background-origin: padding-box, border-box;
background-clip: padding-box, border-box;
}
.health-overall h3 {
color: #D60C52;
font-size: 1.3em;
margin-bottom: 15px;
font-weight: bold;
}
.health-overall .health-indicator {
font-size: 5em;
margin-bottom: 10px;
}
.health-overall .health-label {
font-size: 1.5em;
font-weight: bold;
margin-bottom: 10px;
}
.health-overall .health-detail {
color: #666;
font-size: 1em;
line-height: 1.6;
}
/* 判断基準ボックス */
.health-criteria {
background: #F8FAFC;
border-radius: 12px;
padding: 15px;
margin-bottom: 15px;
display: flex;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
font-size: 0.9em;
}
.health-criteria-item {
display: flex;
align-items: center;
gap: 8px;
color: #555;
font-weight: 500;
}
.health-criteria-icon {
font-size: 1.2em;
}
/* カテゴリ別カード (2x2) */
.health-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
@media (max-width: 1024px) {
.health-grid {
grid-template-columns: 1fr;
}
}
.health-card {
background: white;
border-radius: 20px;
padding: 25px;
box-shadow: 0 4px 16px rgba(214, 12, 82, 0.08), 0 2px 8px rgba(36, 96, 158, 0.06);
text-align: center;
transition: transform 0.3s ease, box-shadow 0.3s ease, border 0.3s ease;
border: 2px solid transparent;
position: relative;
overflow: hidden;
}
.health-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #D60C52, #24609E);
opacity: 0;
transition: opacity 0.3s ease;
}
.health-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 24px rgba(214, 12, 82, 0.2);
border-color: rgba(214, 12, 82, 0.2);
}
.health-card:hover::before {
opacity: 1;
}
.health-card h3 {
color: #666;
font-size: 1.1em;
margin-bottom: 15px;
}
.health-indicator {
font-size: 4em;
margin-bottom: 10px;
}
.health-label {
font-size: 1.3em;
font-weight: bold;
margin-bottom: 10px;
}
.health-label.good {
color: #27ae60;
}
.health-label.warning {
color: #f39c12;
}
.health-label.danger {
color: #e74c3c;
}
.health-detail {
color: #999;
font-size: 0.9em;
line-height: 1.6;
}
/* AIサジェストエリア */
.ai-suggestions {
margin-bottom: 40px;
position: relative;
}
.ai-suggestions::after {
content: '';
display: block;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(214, 12, 82, 0.35), rgba(36, 96, 158, 0.35), transparent);
margin-top: 40px;
border-radius: 2px;
}
.ai-suggestions h2 {
color: #333;
font-size: 1.8em;
margin-bottom: 30px;
text-align: center;
position: relative;
text-shadow: 0 2px 12px rgba(214, 12, 82, 0.12);
}
.ai-suggestions h2::before {
content: '🤖 ';
}
.ai-suggestions-meta {
display: flex;
justify-content: center;
gap: 20px;
font-size: 0.9em;
color: #555;
margin-bottom: 15px;
flex-wrap: wrap;
}
.ai-suggestions-note {
background: #fff8f1;
border-left: 4px solid #ffa94d;
padding: 10px 15px;
border-radius: 8px;
color: #8c4b16;
margin-bottom: 20px;
font-size: 0.9em;
}
.suggestion-item {
background: white;
border-radius: 20px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 4px 16px rgba(214, 12, 82, 0.08), 0 2px 8px rgba(36, 96, 158, 0.06);
display: flex;
align-items: flex-start;
gap: 15px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
position: relative;
}
.suggestion-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: linear-gradient(180deg, #D60C52, #24609E);
border-radius: 20px 0 0 20px;
opacity: 0;
transition: opacity 0.3s ease;
}
.suggestion-item:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(214, 12, 82, 0.15);
}
.suggestion-item:hover::before {
opacity: 1;
}
.suggestion-item:last-child {
margin-bottom: 0;
}
.suggestion-icon {
font-size: 2em;
flex-shrink: 0;
}
.suggestion-content {
flex: 1;
}
.suggestion-content h3 {
color: #333;
font-size: 1.2em;
margin-bottom: 8px;
}
.suggestion-content p {
color: #666;
line-height: 1.6;
margin-bottom: 10px;
}
.suggestion-action {
background: #F8FAFC;
border-left: 4px solid #D60C52;
padding: 10px 15px;
border-radius: 8px;
margin-top: 10px;
}
.suggestion-action strong {
color: #D60C52;
display: block;
margin-bottom: 5px;
}
.suggestion-action p {
font-size: 0.95em;
color: #555;
margin: 0;
}
.suggestion-action ul {
margin: 0;
padding-left: 18px;
color: #555;
}
.suggestion-action li {
margin-bottom: 4px;
}
.suggestion-priority {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: bold;
margin-bottom: 8px;
}
.priority-high {
background: #fee;
color: #c33;
}
.priority-medium {
background: #fff3cd;
color: #8a6d3b;
}
.priority-info {
background: #dfe6e9;
color: #2d3436;
}
.priority-low {
background: #e8f5e9;
color: #2e7d32;
}
/* マガジン別ステータスセクション */
.details-section {
margin-bottom: 40px;
position: relative;
}
.details-section::after {
content: '';
display: block;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(214, 12, 82, 0.35), rgba(36, 96, 158, 0.35), transparent);
margin-top: 40px;
border-radius: 2px;
}
.details-section h2 {
color: #333;
font-size: 1.8em;
margin-bottom: 30px;
text-align: center;
position: relative;
text-shadow: 0 2px 12px rgba(214, 12, 82, 0.12);
}
/* 凡例 */
.status-legend {
background: #F8FAFC;
border-radius: 12px;
padding: 15px;
margin-bottom: 25px;
display: flex;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9em;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.legend-dot.good {
background: #d4edda;
border: 2px solid #155724;
}
.legend-dot.warning {
background: #fff3cd;
border: 2px solid #856404;
}
.legend-dot.overdue {
background: #f8d7da;
border: 2px solid #721c24;
}
.legend-label {
color: #555;
font-weight: 500;
}
.process-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
margin-bottom: 40px;
}
@media (max-width: 768px) {
.process-grid {
grid-template-columns: 1fr;
}
}
.process-section {
background: white;
border-radius: 20px;
padding: 25px;
box-shadow: 0 4px 16px rgba(214, 12, 82, 0.08), 0 2px 8px rgba(36, 96, 158, 0.06);
position: relative;
border: 2px solid transparent;
transition: border 0.3s ease, box-shadow 0.3s ease;
}
.process-section::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
right: 2px;
height: 4px;
background: linear-gradient(90deg, #D60C52, #24609E);
border-radius: 18px 18px 0 0;
}
.process-section:hover {
border-color: rgba(214, 12, 82, 0.15);
box-shadow: 0 6px 20px rgba(214, 12, 82, 0.12);
}
.process-section h3 {
font-size: 1.5em;
margin-bottom: 20px;
color: #333;
display: flex;
align-items: center;
gap: 10px;
}
.status-group {
margin-bottom: 25px;
}
.status-group:last-child {
margin-bottom: 0;
}
.status-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid #eee;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.status-name {
font-weight: bold;
color: #333;
flex: 1;
}
.status-count {
background: #F8FAFC;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.9em;
color: #666;
}
.task-list {
padding-left: 0;
list-style: none;
}
.task-item {
padding: 10px 0;
border-bottom: 1px solid #f5f5f5;
transition: padding-left 0.2s ease, background 0.2s ease;
border-radius: 8px;
}
.task-item:hover {
padding-left: 10px;
background: linear-gradient(90deg, rgba(214, 12, 82, 0.02), transparent);
}
.task-item:last-child {
border-bottom: none;
}
.task-title {
color: #333;
font-weight: 500;
margin-bottom: 5px;
}
.task-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.task-assignee,
.task-due {
font-size: 0.85em;
padding: 3px 10px;
border-radius: 10px;
}
.task-assignee {
background: #e3f2fd;
color: #1976d2;
}
.task-due {
background: #fff3cd;
color: #856404;
display: inline-flex;
align-items: center;
}
.task-due.good {
background: #d4edda;
color: #155724;
}
.task-due.warning {
background: #fff3cd;
color: #856404;
}
.task-due.overdue {
background: #f8d7da;
color: #721c24;
font-weight: bold;
}
.task-current-process {
font-size: 0.85em;
color: #555;
background: #F8FAFC;
padding: 3px 10px;
border-radius: 10px;
font-weight: 500;
}
.task-detail {
font-size: 0.8em;
color: #d63031;
margin-top: 5px;
padding-left: 10px;
border-left: 3px solid #d63031;
font-weight: 500;
}
.due-label {
font-size: 0.75em;
color: #999;
margin-right: 3px;
}
/* カレンダー */
.calendar-section {
margin-bottom: 40px;
}
.calendar-section h2 {
color: #333;
font-size: 1.8em;
margin-bottom: 20px;
text-align: center;
position: relative;
text-shadow: 0 2px 12px rgba(214, 12, 82, 0.12);
}
.calendar-legend {
background: #F8FAFC;
border-radius: 12px;
padding: 15px;
margin-bottom: 25px;
display: flex;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
}
.calendar-legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9em;
}
.calendar-legend-box {
width: 20px;
height: 16px;
border-radius: 4px;
}
.calendar-legend-box.manuscript {
background: linear-gradient(135deg, #3B82F6, #60A5FA);
}
.calendar-legend-box.video {
background: linear-gradient(135deg, #F59E0B, #FBBF24);
}
.calendar-legend-box.published {
background: linear-gradient(135deg, #10B981, #34D399);
}
.calendar-legend-label {
color: #555;
font-weight: 500;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
margin-bottom: 20px;
}
.calendar-header {
text-align: center;
font-weight: 600;
padding: 10px;
background: linear-gradient(20deg, rgba(214, 12, 82, 0.15), rgba(36, 96, 158, 0.15));
color: #666;
border-radius: 12px;
font-size: 0.9em;
}
.calendar-day {
border: 2px solid #e0e0e0;
border-radius: 12px;
padding: 8px;
min-height: 100px;
background: white;
position: relative;
}
.calendar-day.today {
border-color: #D60C52;
border-width: 3px;
background: #fff3e0;
}
.calendar-day-number {
font-weight: bold;
color: #333;
margin-bottom: 5px;
font-size: 0.85em;
}
.calendar-task {
padding: 4px 8px;
border-radius: 8px;
font-size: 0.7em;
margin: 4px 0;
color: white;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
max-width: 100%;
box-sizing: border-box;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.calendar-task:hover {
transform: scale(1.05);
}
/* 原稿執筆中 - 青系 */
.calendar-task.phase-manuscript {
background: linear-gradient(135deg, #3B82F6, #60A5FA);
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
}
.calendar-task.phase-manuscript:hover {
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.4);
}
/* 動画制作中 - オレンジ系 */
.calendar-task.phase-video {
background: linear-gradient(135deg, #F59E0B, #FBBF24);
box-shadow: 0 2px 4px rgba(245, 158, 11, 0.3);
}
.calendar-task.phase-video:hover {
box-shadow: 0 4px 8px rgba(245, 158, 11, 0.4);
}
/* 公開済み - 緑系 */
.calendar-task.phase-published {
background: linear-gradient(135deg, #10B981, #34D399);
box-shadow: 0 2px 4px rgba(16, 185, 129, 0.3);
}
.calendar-task.phase-published:hover {
box-shadow: 0 4px 8px rgba(16, 185, 129, 0.4);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.container > * {
animation: fadeIn 0.5s ease forwards;
}

View File

@ -0,0 +1,16 @@
# Health Indicator Thresholds
# マガジンステータスダッシュボードの健康度判定閾値
stock:
healthy: 8 # ≥8本 = 🟢 順調
warning: 5 # 5-7本 = 🟡 注意
# <5本 = 🔴 危険
delay:
healthy: 0 # 遅延なし = 🟢 順調
warning: 1 # 1日 = 🟡 注意
# ≥2日 = 🔴 危険
unset_deadline:
status: warning # 期限未設定 = 🟡 注意
show_tag: true # 「期限未設定」タグを表示

51
config/mappings.js Normal file
View File

@ -0,0 +1,51 @@
import { TITLE_EMOJI_RULES } from './settings.js';
// 部分一致で判定(ユーザー名のどこかに含まれればマッチ)
// あなたのチームメンバーに合わせて変更してください
export const ASSIGNEE_MAPPINGS_INCLUDE = {
'taro_yamada': '山田',
'hanako_sato': '佐藤',
};
// 前方一致で判定(ユーザー名の先頭が一致すればマッチ)
export const ASSIGNEE_MAPPINGS_STARTS_WITH = {
'jiro': '田中',
};
/**
* 担当者名を表示名に変換
*/
export function convertAssigneeName(assigneeName) {
if (!assigneeName) {
return '未割当';
}
const lowerName = assigneeName.toLowerCase();
// 部分一致チェック
for (const [key, displayName] of Object.entries(ASSIGNEE_MAPPINGS_INCLUDE)) {
if (lowerName.includes(key)) {
return displayName;
}
}
// 前方一致チェック
for (const [key, displayName] of Object.entries(ASSIGNEE_MAPPINGS_STARTS_WITH)) {
if (lowerName.startsWith(key)) {
return displayName;
}
}
return assigneeName;
}
/**
* タイトルの装飾をルールに基づいて絵文字に変換
*/
export function convertTitleEmoji(title) {
let converted = title;
for (const rule of TITLE_EMOJI_RULES) {
converted = converted.replace(rule.pattern, rule.emoji);
}
return converted;
}

27
config/settings.js Normal file
View File

@ -0,0 +1,27 @@
import 'dotenv/config';
export const LINEAR_API_URL = 'https://api.linear.app/graphql';
export const LINEAR_TEAM_KEY = process.env.MAGAZINE_LINEAR_TEAM_KEY || 'YOUR_TEAM';
export const LABEL_GROUPS = {
parentStatus: 'マガジン作成ステータス(イシュー)',
// Linear 上のラベル名が「スタータス」表記のため、そのまま記載
subIssueStatus: 'マガジン作成スタータス詳細(サブイシュー)',
};
export const STATUS_LABELS = {
stock: '1.企画案ストック',
composition: '2.構成作成中',
manuscript: '3.原稿執筆中',
video: '4.動画編集中',
};
// 隔週サイクル計算の基準日(月曜日)。自分のプロジェクトに合わせて変更してください
export const BIWEEKLY_EPOCH = new Date(2026, 0, 5);
// タイトルの装飾変換ルール。自分のプロジェクトに合わせて変更してください
export const TITLE_EMOJI_RULES = [
{ pattern: /【通常[^】]*】/g, emoji: '☕' },
{ pattern: /【(トピック|ニュース)[^】]*】/g, emoji: '🌐' },
{ pattern: /【教養[^】]*】/g, emoji: '🌐' },
];

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "progress-dashboard",
"version": "1.0.0",
"description": "進捗管理ダッシュボード: Linear + AI + Surge + Slack のパイプライン",
"type": "module",
"scripts": {
"fetch-data": "node scripts/fetch-data.js",
"calculate-health": "node scripts/calculate-health.js",
"ai-suggestions": "node scripts/generate-ai-suggestions.js",
"generate-dashboard": "node scripts/generate-dashboard.js",
"deploy": "node scripts/deploy-to-surge.js",
"screenshot": "node scripts/take-screenshot.js",
"post-slack": "node scripts/post-to-slack.js",
"post-slack:dry-run": "DRY_RUN=1 node scripts/post-to-slack.js",
"run-all": "npm run fetch-data && npm run calculate-health && npm run ai-suggestions && npm run generate-dashboard && npm run deploy && npm run screenshot && npm run post-slack"
},
"keywords": [
"dashboard",
"linear",
"slack",
"ai",
"gemini",
"visualization"
],
"author": "",
"license": "ISC",
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@slack/web-api": "^7.0.0",
"dotenv": "^17.2.3",
"js-yaml": "^4.1.0",
"playwright": "^1.50.1"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@ -0,0 +1,254 @@
import { convertAssigneeName } from '../config/mappings.js';
function cleanTitle(title) {
if (!title) {
return 'タイトル未設定';
}
return title.replace(/\s+/g, ' ').trim();
}
function formatBulletLines(lines) {
return lines
.map(line => line.trim())
.filter(Boolean)
.map(line => `${line}`)
.join('\n');
}
function listMagazineSamples(magazines, limit = 3) {
if (!magazines || magazines.length === 0) {
return '';
}
const names = magazines.slice(0, limit).map(m => `${cleanTitle(m.title)}`);
if (magazines.length > limit) {
return `${names.join('、')} など${magazines.length}`;
}
return names.join('、');
}
export function generateFallbackSuggestions(healthData) {
const magazines = Array.isArray(healthData?.magazines) ? healthData.magazines : [];
const activeMagazines = magazines.filter(mag => mag.state?.type !== 'completed');
const redMagazines = activeMagazines.filter(mag => mag.displayHealthStatus?.status === '🔴');
const yellowMagazines = activeMagazines.filter(mag => mag.displayHealthStatus?.status === '🟡');
const deadlineNotSetMagazines = activeMagazines.filter(mag => mag.displayHealthStatus?.isDeadlineNotSet);
const manuscriptCount = healthData?.summary?.['3.原稿執筆中'] ?? 0;
const videoCount = healthData?.summary?.['4.動画編集中'] ?? 0;
const stockCount = healthData?.planStockHealth?.stockCount ?? 0;
const highSuggestion = buildHighPrioritySuggestion({
redMagazines,
deadlineNotSetMagazines,
yellowMagazines
});
const mediumSuggestion = buildMediumPrioritySuggestion({
healthData,
redMagazines,
yellowMagazines,
highFocusIds: new Set(highSuggestion.focusIds)
});
const lowSuggestion = buildLowPrioritySuggestion({
stockCount,
healthData,
manuscriptCount,
videoCount
});
return [highSuggestion.payload, mediumSuggestion.payload, lowSuggestion.payload];
}
function buildHighPrioritySuggestion({ redMagazines, deadlineNotSetMagazines, yellowMagazines }) {
const focusIds = [];
if (redMagazines.length > 0) {
const target = redMagazines[0];
focusIds.push(target.id);
const detail = target.displayHealthStatus?.message || '重大な遅延';
const assignee = convertAssigneeName(target.assignee?.name);
return {
focusIds,
payload: {
priority: 'high',
priorityLabel: '優先度:高',
problem: `${cleanTitle(target.title)}」で${detail}が発生しており、公開計画に直結するリスクがあります。`,
action: formatBulletLines([
`${assignee} と今日中に状況ヒアリングを行い、ブロッカーを特定する`,
'必要があればリソース追加やスケジュール再調整を即時に決める',
'対応策と更新後の公開予定日をSlack #your-channel で共有する'
])
}
};
}
if (deadlineNotSetMagazines.length > 0) {
const target = deadlineNotSetMagazines[0];
focusIds.push(target.id);
const assignee = convertAssigneeName(target.assignee?.name);
return {
focusIds,
payload: {
priority: 'high',
priorityLabel: '優先度:高',
problem: `${cleanTitle(target.title)}」で期限未設定のタスクが残っており、遅延リスクが顕在化しています。`,
action: formatBulletLines([
`${assignee} と本日中に期限を設定し、工程表を最新化する`,
'期限設定後に関係者へ確認依頼を送り、認識を揃える',
'リマインダー設定やタスク分割で再発防止策を入れる'
])
}
};
}
if (yellowMagazines.length > 0) {
const target = yellowMagazines[0];
focusIds.push(target.id);
const detail = target.displayHealthStatus?.message || '軽微な遅延';
const assignee = convertAssigneeName(target.assignee?.name);
return {
focusIds,
payload: {
priority: 'high',
priorityLabel: '優先度:高',
problem: `注意ステータスのマガジン(例:${listMagazineSamples([target], 1)})があり、進行に黄信号が出ています。`,
action: formatBulletLines([
`${assignee} に48時間以内の巻き返しプランを依頼する`,
`遅延内容(${detail})の解消に必要な支援を洗い出す`,
'進捗確認の頻度を一時的に上げ、対応状況を追跡する'
])
}
};
}
return {
focusIds,
payload: {
priority: 'high',
priorityLabel: '優先度:高',
problem: '重大な遅延は発生していませんが、本日中に重要工程の進行確認を行うと安心です。',
action: formatBulletLines([
'原稿・動画それぞれの最優先マガジンについて担当者に現状確認を行う',
'公開予定日が近い案件のリスク要因を共有し先手の支援を検討する',
'夕方時点で進捗サマリーをSlackに投稿し、チームで状況を可視化する'
])
}
};
}
function buildMediumPrioritySuggestion({ healthData, redMagazines, yellowMagazines, highFocusIds }) {
const remainingYellow = yellowMagazines.filter(mag => !highFocusIds.has(mag.id));
const manuscriptStatus = healthData?.manuscriptHealth?.status;
const videoStatus = healthData?.videoHealth?.status;
if (remainingYellow.length > 0) {
return {
payload: {
priority: 'medium',
priorityLabel: '優先度:中',
problem: `注意ステータスのマガジンが${remainingYellow.length}件あり、短期的なフォローが必要です。`,
action: formatBulletLines([
`${listMagazineSamples(remainingYellow)} の担当者と今週中にフォロー面談を設定する`,
'遅延要因と必要なサポートを洗い出し、ToDo化して共有する',
'タスク完了までのマイルストーンを整理し、進捗トラッキングを強化する'
])
}
};
}
if (manuscriptStatus === '🟡' || videoStatus === '🟡') {
const targetPhase = manuscriptStatus === '🟡' ? '原稿工程' : '動画工程';
return {
payload: {
priority: 'medium',
priorityLabel: '優先度:中',
problem: `${targetPhase}が注意ステータスのため、今週中のリカバリー計画立案が効果的です。`,
action: formatBulletLines([
`${targetPhase}のボトルネック工程を特定し、担当者とのタスク分担を見直す`,
'必要なレビュー枠や外部リソース活用可否を検討する',
'リカバリー計画をチームドキュメント化し、朝会で共有する'
])
}
};
}
if (redMagazines.length > 1) {
return {
payload: {
priority: 'medium',
priorityLabel: '優先度:中',
problem: `複数マガジンで深刻な遅延が見られるため、週内に原因分析と予防策整理が必要です。`,
action: formatBulletLines([
'遅延案件ごとに原因カテゴリを分類し、再発防止策を洗い出す',
'来週以降のリソース計画を再調整し、リードタイムを確保する',
'改善策と意思決定事項をNotion/Slackで共有して定着させる'
])
}
};
}
return {
payload: {
priority: 'medium',
priorityLabel: '優先度:中',
problem: '全体的に順調なものの、今週中に工程間の引き継ぎ手順を見直すと効率が向上します。',
action: formatBulletLines([
'原稿→動画の引き継ぎフォーマットを確認し、必要ならテンプレートを更新する',
'来週公開予定のマガジンについて動画チームへ早期に素材共有する',
'工程レビューの定例化やチェックリスト整備を検討する'
])
}
};
}
function buildLowPrioritySuggestion({ stockCount, healthData, manuscriptCount, videoCount }) {
const stockStatus = healthData?.planStockHealth?.status;
if (stockStatus !== '🟢') {
return {
payload: {
priority: 'low',
priorityLabel: '優先度:低',
problem: `企画ストックが${stockCount}件で安定ラインを下回りつつあるため、来月に向けた仕込みが必要です。`,
action: formatBulletLines([
'来週の企画会議で優先テーマ候補を3件以上ピックアップする',
'過去のヒット企画を分析し、再現性のある型を整理する',
'外部ライターやパートナーの候補を洗い出し、接点作りを進める'
])
}
};
}
if (manuscriptCount > videoCount + 2) {
return {
payload: {
priority: 'low',
priorityLabel: '優先度:低',
problem: `原稿工程に対して動画工程の着手数が少なく、今後の滞留リスクがあります。`,
action: formatBulletLines([
'動画着手準備が整っているマガジンを棚卸しし、編集チームへ共有する',
'動画工程の所要日数を見直し、平準化に向けた目安を設定する',
'外部編集リソースの追加やテンプレート整備を検討する'
])
}
};
}
return {
payload: {
priority: 'low',
priorityLabel: '優先度:低',
problem: '全体進行は安定しているため、長期的な改善としてナレッジ蓄積を進める余地があります。',
action: formatBulletLines([
'今月の成功事例を1本選び、ベストプラクティスをドキュメント化する',
'健康度の推移を見える化し、定例で振り返れるダッシュボード項目を検討する',
'AI提案のヒット率を記録し、プロンプト改善のPDCAを回す'
])
}
};
}

687
scripts/calculate-health.js Normal file
View File

@ -0,0 +1,687 @@
#!/usr/bin/env node
/**
* Calculate health indicators for magazine dashboard (4-category version)
*
* Categories:
* 1. 企画案ストック (snapshot-based weekly cycle)
* 2. 構成作成 (completedAt-based biweekly cycle)
* 3. 原稿執筆 (deadline delay-based, realtime)
* 4. 動画編集 (deadline delay-based, realtime)
*/
import 'dotenv/config';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import yaml from 'js-yaml';
import { LABEL_GROUPS, STATUS_LABELS, BIWEEKLY_EPOCH } from '../config/settings.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// --- Thresholds ---
async function loadThresholds() {
const configPath = path.join(__dirname, '..', 'config', 'health-thresholds.yaml');
const configContent = await fs.readFile(configPath, 'utf-8');
return yaml.load(configContent);
}
// --- Shared helpers ---
function extractStatusLabels(subIssues, labelGroupName) {
const labels = new Set();
subIssues.forEach(sub => {
sub.labels.forEach(label => {
if (label.parent && label.parent.name === labelGroupName) {
labels.add(label.name);
}
});
});
return Array.from(labels);
}
function getMaxProcessNumber(labelNames) {
const numbers = labelNames
.map(name => {
const match = name.match(/^(\d+)\./);
return match ? parseInt(match[1], 10) : null;
})
.filter(n => n !== null);
return numbers.length > 0 ? Math.max(...numbers) : null;
}
function sortLabelsByNumber(labelNames) {
return labelNames.sort((a, b) => {
const numA = parseInt(a.match(/^(\d+)\./)?.[1] || '999', 10);
const numB = parseInt(b.match(/^(\d+)\./)?.[1] || '999', 10);
return numA - numB;
});
}
function findProcessInActiveIssues(subIssues, labelGroupName, processNumber) {
const targetPrefix = `${processNumber}.`;
const activeSubIssues = subIssues.filter(sub =>
sub.state.type !== 'completed' &&
sub.state.type !== 'started' &&
sub.state.type !== 'canceled'
);
for (const sub of activeSubIssues) {
for (const label of sub.labels) {
if (label.parent && label.parent.name === labelGroupName && label.name.startsWith(targetPrefix)) {
return label.name;
}
}
}
return null;
}
function determineCurrentProcesses(magazine) {
const inProgressSubIssues = magazine.subIssues.filter(sub => sub.state.name === 'In Progress');
const inProgressLabels = extractStatusLabels(inProgressSubIssues, LABEL_GROUPS.subIssueStatus);
if (inProgressLabels.length > 0) return sortLabelsByNumber(inProgressLabels);
const doneSubIssues = magazine.subIssues.filter(sub => sub.state.type === 'completed');
const doneLabels = extractStatusLabels(doneSubIssues, LABEL_GROUPS.subIssueStatus);
const maxProcessNumber = getMaxProcessNumber(doneLabels);
if (maxProcessNumber === null) return [];
const sameProcess = findProcessInActiveIssues(magazine.subIssues, LABEL_GROUPS.subIssueStatus, maxProcessNumber);
if (sameProcess) return [sameProcess];
const nextProcess = findProcessInActiveIssues(magazine.subIssues, LABEL_GROUPS.subIssueStatus, maxProcessNumber + 1);
if (nextProcess) return [nextProcess];
return [];
}
function calculateDelay(subIssue, today) {
if (!subIssue.dueDate) {
return { status: '🟡', label: '注意', days: null, tag: '期限未設定' };
}
const due = new Date(subIssue.dueDate);
const diffMs = today - due;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays <= 0) return { status: '🟢', label: '順調', days: 0 };
if (diffDays <= 1) return { status: '🟡', label: '注意', days: diffDays };
return { status: '🔴', label: '危険', days: diffDays };
}
// --- Date helpers ---
function formatLocalDate(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}`;
}
function getMondayOfWeek(date) {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const day = d.getDay();
const diff = day === 0 ? -6 : 1 - day;
d.setDate(d.getDate() + diff);
return d;
}
function getDayOfWeek(date) {
return date.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
}
// --- Category 1: 企画案ストック (snapshot-based) ---
async function loadStockTracker() {
const trackerPath = path.join(__dirname, '..', 'data', 'stock-tracker.json');
try {
const content = await fs.readFile(trackerPath, 'utf-8');
return JSON.parse(content);
} catch {
return null;
}
}
async function saveStockTracker(tracker) {
const trackerPath = path.join(__dirname, '..', 'data', 'stock-tracker.json');
await fs.mkdir(path.dirname(trackerPath), { recursive: true });
await fs.writeFile(trackerPath, JSON.stringify(tracker, null, 2));
}
function calculateStockHealth(magazines, today) {
const stockMagazines = magazines.filter(m => m.label === STATUS_LABELS.stock);
const currentIds = stockMagazines.map(m => m.id);
const stockCount = currentIds.length;
return {
currentIds,
stockCount,
stockMagazines,
};
}
async function processStockSnapshot(magazines, today) {
const { currentIds, stockCount } = calculateStockHealth(magazines, today);
const mondayOfThisWeek = getMondayOfWeek(today);
const weekStartStr = formatLocalDate(mondayOfThisWeek);
const todayStr = formatLocalDate(today);
let tracker = await loadStockTracker();
if (!tracker || tracker.weekStart !== weekStartStr) {
tracker = {
weekStart: weekStartStr,
snapshots: [{ date: todayStr, ids: currentIds }],
weeklyNewIds: [],
};
} else {
tracker.snapshots.push({ date: todayStr, ids: currentIds });
}
const firstSnapshot = tracker.snapshots[0];
const firstIds = new Set(firstSnapshot.ids);
const newIds = currentIds.filter(id => !firstIds.has(id));
tracker.weeklyNewIds = newIds;
await saveStockTracker(tracker);
const weeklyNewCount = newIds.length;
const dayOfWeek = getDayOfWeek(today);
// Judgement table from design spec
let status = '🟢';
let label = '順調';
let shortReason = '';
if (dayOfWeek >= 5 || dayOfWeek === 0) {
// Friday(5), Saturday(6), Sunday(0)
if (weeklyNewCount >= 2) {
status = '🟢'; label = '順調';
} else {
status = '🔴'; label = '危険';
shortReason = '企画の新規追加が不足しています';
}
} else if (dayOfWeek >= 4) {
// Thursday(4)
if (weeklyNewCount >= 1) {
status = '🟢'; label = '順調';
} else {
status = '🟡'; label = '注意';
shortReason = '企画の新規追加が不足しています';
}
}
// Mon-Wed: always green
return {
status,
label,
shortReason,
weeklyNewCount,
weeklyTarget: 2,
stockCount,
details: `今週の新規追加:${weeklyNewCount} / 2件`,
};
}
// --- Category 2: 構成作成 (completedAt-based biweekly cycle) ---
function getBiweeklyStart(today) {
// Epoch Monday: 2026-01-05 (a known Monday)
const epoch = new Date(BIWEEKLY_EPOCH);
const diffMs = today - epoch;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffWeeks = Math.floor(diffDays / 7);
const cycleWeeks = Math.floor(diffWeeks / 2) * 2;
const cycleStart = new Date(epoch);
cycleStart.setDate(cycleStart.getDate() + cycleWeeks * 7);
return cycleStart;
}
function calculateCompositionHealth(magazines, today) {
const compositionMagazines = magazines.filter(m => m.label === STATUS_LABELS.composition);
const cycleStart = getBiweeklyStart(today);
const cycleEnd = new Date(cycleStart);
cycleEnd.setDate(cycleEnd.getDate() + 14);
let completedCount = 0;
compositionMagazines.forEach(mag => {
mag.subIssues.forEach(sub => {
const hasCompositionLabel = sub.labels.some(l =>
l.parent && l.parent.name === LABEL_GROUPS.subIssueStatus && l.name.startsWith('1.')
);
if (hasCompositionLabel && sub.completedAt) {
const completed = new Date(sub.completedAt);
if (completed >= cycleStart && completed < cycleEnd) {
completedCount++;
}
}
});
});
const daysSinceCycleStart = Math.floor((today - cycleStart) / (1000 * 60 * 60 * 24));
const dayOfWeek = getDayOfWeek(today);
const isWeek2 = daysSinceCycleStart >= 7;
let status = '🟢';
let label = '順調';
let shortReason = '';
let target = 3;
if (isWeek2) {
if (dayOfWeek >= 6) {
// Week2 Sat
target = 3;
if (completedCount >= 3) { status = '🟢'; label = '順調'; }
else { status = '🔴'; label = '危険'; shortReason = `構成が遅れています(完了 ${completedCount} / 目標 ${target}本)`; }
} else if (dayOfWeek >= 4) {
// Week2 Thu-Fri
target = 3;
if (completedCount >= 3) { status = '🟢'; label = '順調'; }
else if (completedCount >= 2) { status = '🟡'; label = '注意'; shortReason = `構成が遅れています(完了 ${completedCount} / 目標 ${target}本)`; }
else { status = '🔴'; label = '危険'; shortReason = `構成が遅れています(完了 ${completedCount} / 目標 ${target}本)`; }
} else if (dayOfWeek >= 1) {
// Week2 Mon-Wed
target = 2;
if (completedCount >= 2) { status = '🟢'; label = '順調'; }
else if (completedCount >= 1) { status = '🟡'; label = '注意'; shortReason = `構成が遅れています(完了 ${completedCount} / 目標 ${target}本)`; }
else { status = '🔴'; label = '危険'; shortReason = `構成が遅れています(完了 ${completedCount} / 目標 ${target}本)`; }
} else {
// Week2 Sun
target = 3;
if (completedCount >= 3) { status = '🟢'; label = '順調'; }
else { status = '🔴'; label = '危険'; shortReason = `構成が遅れています(完了 ${completedCount} / 目標 ${target}本)`; }
}
} else {
// Week 1
if (dayOfWeek >= 5 || dayOfWeek === 0) {
// Week1 Fri-Sun
target = 1;
if (completedCount >= 1) { status = '🟢'; label = '順調'; }
else { status = '🟡'; label = '注意'; shortReason = `構成が遅れています(完了 ${completedCount} / 目標 ${target}本)`; }
}
// Week1 Mon-Thu: always green
}
const cycleStartStr = `${cycleStart.getFullYear()}/${cycleStart.getMonth() + 1}/${cycleStart.getDate()}`;
const cycleEndDate = new Date(cycleEnd);
cycleEndDate.setDate(cycleEndDate.getDate() - 1);
const cycleEndStr = `${cycleEndDate.getMonth() + 1}/${cycleEndDate.getDate()}`;
return {
status,
label,
shortReason,
completedCount,
target,
details: `完了:${completedCount} / ${target}`,
cyclePeriod: `${cycleStartStr} - ${cycleEndStr}`,
};
}
// --- Category 3 & 4: 原稿執筆 / 動画編集 (existing logic) ---
function checkProcessDelays(subIssues, maxProcessNumber) {
const today = new Date();
today.setHours(0, 0, 0, 0);
let maxDelayDays = 0;
let delayedProcess = null;
subIssues.forEach(sub => {
if (sub.state?.type === 'completed' || sub.state?.type === 'canceled') return;
sub.labels.forEach(label => {
if (label.parent && label.parent.name === LABEL_GROUPS.subIssueStatus) {
const match = label.name.match(/^(\d+)\./);
if (match) {
const processNumber = parseInt(match[1], 10);
if (processNumber <= maxProcessNumber && sub.dueDate) {
const due = new Date(sub.dueDate);
due.setHours(0, 0, 0, 0);
const delayDays = Math.floor((today - due) / (1000 * 60 * 60 * 24));
if (delayDays > maxDelayDays) {
maxDelayDays = delayDays;
delayedProcess = label.name;
}
}
}
}
});
});
let status = '🟢';
if (maxDelayDays > 1) status = '🔴';
else if (maxDelayDays > 0) status = '🟡';
return { hasDelay: maxDelayDays > 0, delayDays: maxDelayDays, delayedProcess, status };
}
function checkNonDoneDelays(subIssues) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const delayedSubIssues = [];
subIssues.forEach(sub => {
if (sub.state?.type === 'completed' || sub.state?.type === 'canceled') return;
if (sub.dueDate) {
const due = new Date(sub.dueDate);
due.setHours(0, 0, 0, 0);
const delayDays = Math.floor((today - due) / (1000 * 60 * 60 * 24));
if (delayDays > 0) {
const processLabel = sub.labels.find(label =>
label.parent && label.parent.name === LABEL_GROUPS.subIssueStatus
);
delayedSubIssues.push({
title: sub.title,
delayDays,
processLabel: processLabel ? processLabel.name : sub.title
});
}
}
});
return delayedSubIssues;
}
function determineDisplayHealthStatus(magazine, today) {
const { label, subIssues, dueDate: publishDate } = magazine;
let displayHealthStatus = { status: '🟢', message: '', isDeadlineNotSet: false };
if (label === '3.原稿執筆中') {
const processDelays = checkProcessDelays(subIssues, 2);
const manuscriptSubs = subIssues.filter(sub =>
sub.labels && sub.labels.some(l =>
l.parent && l.parent.name === LABEL_GROUPS.subIssueStatus && l.name.includes('原稿')
)
);
let manuscriptDueDate = null;
if (manuscriptSubs.length > 0) {
const subsWithDue = manuscriptSubs.filter(sub => sub.dueDate);
if (subsWithDue.length > 0) {
const earliestSub = subsWithDue.reduce((earliest, sub) =>
new Date(sub.dueDate) < new Date(earliest.dueDate) ? sub : earliest
);
manuscriptDueDate = earliestSub.dueDate;
}
}
if (processDelays.status === '🔴') {
displayHealthStatus = {
status: '🔴',
message: processDelays.delayedProcess ? `${processDelays.delayedProcess}${processDelays.delayDays}日遅延` : '遅延あり',
isDeadlineNotSet: false
};
} else if (processDelays.status === '🟡') {
displayHealthStatus = {
status: '🟡',
message: processDelays.delayedProcess ? `${processDelays.delayedProcess}${processDelays.delayDays}日遅延` : '遅延あり',
isDeadlineNotSet: false
};
} else if (!manuscriptDueDate) {
displayHealthStatus = { status: '🟡', message: '原稿期限未設定', isDeadlineNotSet: true };
}
if (!publishDate && displayHealthStatus.status === '🟢') {
displayHealthStatus = { status: '🟡', message: '公開日未設定', isDeadlineNotSet: true };
}
} else if (label === '4.動画編集中') {
const delayedSubs = checkNonDoneDelays(subIssues);
if (delayedSubs.length > 0) {
const maxDelayDays = Math.max(...delayedSubs.map(sub => sub.delayDays));
const delayMessages = delayedSubs.map(sub => `${sub.processLabel}${sub.delayDays}日遅延`);
const status = maxDelayDays > 1 ? '🔴' : '🟡';
displayHealthStatus = { status, message: delayMessages.join('、'), isDeadlineNotSet: false };
}
if (publishDate) {
const todayZero = new Date(today); todayZero.setHours(0, 0, 0, 0);
const publishDateObj = new Date(publishDate); publishDateObj.setHours(0, 0, 0, 0);
const fourDaysBefore = new Date(publishDateObj);
fourDaysBefore.setDate(fourDaysBefore.getDate() - 4);
if (todayZero >= fourDaysBefore && todayZero < publishDateObj) {
const thumbnailSub = subIssues.find(sub =>
sub.labels && sub.labels.some(l =>
l.parent && l.parent.name === LABEL_GROUPS.subIssueStatus && l.name.includes('サムネイル文言')
)
);
if (thumbnailSub && thumbnailSub.state?.type !== 'completed' && thumbnailSub.state?.type !== 'canceled') {
const daysUntilPublish = Math.floor((publishDateObj - todayZero) / (1000 * 60 * 60 * 24));
const thumbnailMessage = `サムネイル文言が未完了(公開${daysUntilPublish}日前)`;
if (displayHealthStatus.message) {
displayHealthStatus = {
status: displayHealthStatus.status === '🔴' ? '🔴' : '🟡',
message: `${displayHealthStatus.message}${thumbnailMessage}`,
isDeadlineNotSet: false
};
} else {
displayHealthStatus = { status: '🟡', message: thumbnailMessage, isDeadlineNotSet: false };
}
}
}
const todayZero2 = new Date(today); todayZero2.setHours(0, 0, 0, 0);
const due = new Date(publishDate); due.setHours(0, 0, 0, 0);
const publishDelayDays = Math.floor((todayZero2 - due) / (1000 * 60 * 60 * 24));
if (publishDelayDays > 0) {
displayHealthStatus = { status: '🔴', message: `公開日を${publishDelayDays}日超過`, isDeadlineNotSet: false };
}
} else {
if (displayHealthStatus.status === '🟢') {
displayHealthStatus = { status: '🟡', message: '公開日未設定', isDeadlineNotSet: true };
}
}
}
return displayHealthStatus;
}
// --- Aggregation with shortReason ---
function classifyReasonType(message) {
if (!message) return null;
if (message.includes('遅延')) return 'delay';
if (message === '原稿期限未設定') return 'deadline-not-set';
if (message === '公開日未設定') return 'publish-date-not-set';
if (message.includes('超過')) return 'publish-date-exceeded';
if (message.includes('サムネイル')) return 'thumbnail-incomplete';
return 'other';
}
function buildReasonTemplates(typeCounts, categoryName) {
const templates = [];
if (typeCounts['delay'])
templates.push(`${categoryName}が遅れています(${typeCounts['delay']}件)`);
if (typeCounts['deadline-not-set'])
templates.push(`原稿の期限が未設定です(${typeCounts['deadline-not-set']}件)`);
if (typeCounts['publish-date-not-set'])
templates.push(`公開日が未設定です(${typeCounts['publish-date-not-set']}件)`);
if (typeCounts['publish-date-exceeded'])
templates.push(`公開日を超過しています(${typeCounts['publish-date-exceeded']}件)`);
if (typeCounts['thumbnail-incomplete'])
templates.push(`サムネイル文言が未完了です(${typeCounts['thumbnail-incomplete']}件)`);
if (typeCounts['other'])
templates.push(`${categoryName}に注意が必要です(${typeCounts['other']}件)`);
return templates;
}
function getWorstHealthStatus(magazines, categoryName) {
if (magazines.length === 0) {
return { status: '🟢', label: '順調', shortReason: '', details: 'タスクなし' };
}
let redCount = 0;
let yellowCount = 0;
const redTypeCounts = {};
const yellowTypeCounts = {};
magazines.forEach(m => {
const msg = m.displayHealthStatus.message;
if (m.displayHealthStatus.status === '🔴') {
redCount++;
const type = classifyReasonType(msg) || 'delay';
redTypeCounts[type] = (redTypeCounts[type] || 0) + 1;
} else if (m.displayHealthStatus.status === '🟡') {
yellowCount++;
const type = classifyReasonType(msg) || 'other';
yellowTypeCounts[type] = (yellowTypeCounts[type] || 0) + 1;
}
});
if (redCount > 0) {
const allTypeCounts = { ...redTypeCounts };
Object.entries(yellowTypeCounts).forEach(([type, count]) => {
allTypeCounts[type] = (allTypeCounts[type] || 0) + count;
});
const templates = buildReasonTemplates(allTypeCounts, categoryName);
return {
status: '🔴',
label: '危険',
shortReason: templates.length > 0
? templates.join('\n')
: `${categoryName}が遅れています(${redCount}件)`,
details: `${redCount}件が遅延中 (合計${magazines.length}件)`
};
}
if (yellowCount > 0) {
const templates = buildReasonTemplates(yellowTypeCounts, categoryName);
return {
status: '🟡',
label: '注意',
shortReason: templates.length > 0
? templates.join('\n')
: `${categoryName}に注意が必要です(${yellowCount}件)`,
details: `${yellowCount}件が注意 (合計${magazines.length}件)`
};
}
return {
status: '🟢',
label: '順調',
shortReason: '',
details: `${magazines.length}件が順調`
};
}
function getWorstOfAll(healthStatuses) {
const hasRed = healthStatuses.some(h => h.status === '🔴');
const hasYellow = healthStatuses.some(h => h.status === '🟡');
const reasons = healthStatuses
.filter(h => h.status !== '🟢' && h.shortReason)
.map(h => h.shortReason);
if (hasRed) {
return {
status: '🔴',
label: '危険',
details: reasons.length > 0 ? reasons.join('\n') : '遅延が発生しています'
};
}
if (hasYellow) {
return {
status: '🟡',
label: '注意',
details: reasons.length > 0 ? reasons.join('\n') : '注意が必要です'
};
}
return {
status: '🟢',
label: '順調',
details: '全てのカテゴリで順調に進んでいます'
};
}
// --- Main ---
async function calculateHealth() {
console.log('🧮 健康度を計算中...');
const dataPath = path.join(__dirname, '..', 'data', 'linear-data.json');
let linearData;
try {
const dataContent = await fs.readFile(dataPath, 'utf-8');
linearData = JSON.parse(dataContent);
} catch (error) {
console.error('❌ linear-data.json の読み込みに失敗しました:', error.message);
console.log('💡 先に npm run fetch-data を実行してください');
process.exit(1);
}
const thresholds = await loadThresholds();
console.log('✅ 閾値設定を読み込みました');
const today = new Date();
const enrichedMagazines = linearData.magazines.map(magazine => {
const currentProcesses = determineCurrentProcesses(magazine);
const displayHealthStatus = determineDisplayHealthStatus(magazine, today);
return {
...magazine,
currentProcesses,
displayHealthStatus,
subIssuesEnriched: magazine.subIssues.map(sub => ({
...sub,
delay: calculateDelay(sub, today)
}))
};
});
// Category 1: 企画案ストック
const planStockHealth = await processStockSnapshot(enrichedMagazines, today);
// Category 2: 構成作成
const compositionHealth = calculateCompositionHealth(enrichedMagazines, today);
// Category 3: 原稿執筆 (3.原稿執筆中)
const manuscriptMagazines = enrichedMagazines.filter(m =>
m.label === STATUS_LABELS.manuscript && m.state?.type !== 'completed'
);
const manuscriptHealth = getWorstHealthStatus(manuscriptMagazines, '原稿');
// Category 4: 動画編集 (4.動画編集中)
const videoMagazines = enrichedMagazines.filter(m =>
m.label === STATUS_LABELS.video && m.state?.type !== 'completed'
);
const videoHealth = getWorstHealthStatus(videoMagazines, '動画');
// Overall
const overallHealth = getWorstOfAll([planStockHealth, compositionHealth, manuscriptHealth, videoHealth]);
const result = {
calculatedAt: new Date().toISOString(),
magazines: enrichedMagazines,
overallHealth,
planStockHealth,
compositionHealth,
manuscriptHealth,
videoHealth,
summary: {
total: enrichedMagazines.length,
'1.企画案ストック': enrichedMagazines.filter(m => m.label === STATUS_LABELS.stock).length,
'2.構成作成中': enrichedMagazines.filter(m => m.label === STATUS_LABELS.composition).length,
'3.原稿執筆中': manuscriptMagazines.length,
'4.動画編集中': videoMagazines.length
},
thresholds
};
const outputPath = path.join(__dirname, '..', 'data', 'health-data.json');
await fs.writeFile(outputPath, JSON.stringify(result, null, 2));
console.log('💾 健康度データを data/health-data.json に保存しました');
console.log('\n📊 健康度サマリー:');
console.log(` 全体: ${overallHealth.status} ${overallHealth.label}`);
console.log(` 企画案ストック: ${planStockHealth.status} ${planStockHealth.label} (新規${planStockHealth.weeklyNewCount}/2件, ストック${planStockHealth.stockCount}件)`);
console.log(` 構成作成: ${compositionHealth.status} ${compositionHealth.label} (完了${compositionHealth.completedCount}/${compositionHealth.target}本)`);
console.log(` 原稿執筆: ${manuscriptHealth.status} ${manuscriptHealth.label}`);
console.log(` 動画編集: ${videoHealth.status} ${videoHealth.label}\n`);
return result;
}
if (import.meta.url === `file://${process.argv[1]}`) {
calculateHealth().catch(error => {
console.error('❌ エラーが発生しました:', error.message);
if (error.stack) console.error(error.stack);
process.exit(1);
});
}
export default calculateHealth;

108
scripts/deploy-to-surge.js Normal file
View File

@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* Deploy dashboard to Surge.sh
*
* Deploys dashboard.html to Surge.sh
*/
import 'dotenv/config';
import { exec } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const execAsync = promisify(exec);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function deployToSurge() {
console.log('🚀 Surge.shにデプロイ中...');
try {
// HTMLファイルの存在確認
const htmlPath = path.join(__dirname, '..', 'output', 'dashboard.html');
try {
await fs.access(htmlPath);
} catch {
console.error('❌ HTMLファイルが見つかりません:', htmlPath);
console.log('💡 先に npm run generate-dashboard を実行してください');
process.exit(1);
}
// デプロイ用ディレクトリを準備
const deployDir = path.join(__dirname, '..', 'dist');
await fs.mkdir(deployDir, { recursive: true });
// HTMLファイルをコピー
const indexPath = path.join(deployDir, 'index.html');
await fs.copyFile(htmlPath, indexPath);
// surge.shのドメイン設定 - 固定ドメインを使用
const domain = process.env.MAGAZINE_SURGE_DOMAIN;
if (!domain) {
console.error('❌ MAGAZINE_SURGE_DOMAIN が設定されていません');
console.log('💡 .env ファイルに MAGAZINE_SURGE_DOMAIN=your-project-dashboard.surge.sh を設定してください');
process.exit(1);
}
console.log(`📝 デプロイ先ドメイン: ${domain}`);
// Surgeでデプロイ
console.log('📤 アップロード中...');
const { stdout, stderr } = await execAsync(
`npx surge --project "${deployDir}" --domain "${domain}"`,
{
env: {
...process.env,
// CI環境でのインタラクティブモードを無効化
CI: 'true',
SURGE_LOGIN: process.env.MAGAZINE_SURGE_LOGIN || '',
SURGE_TOKEN: process.env.MAGAZINE_SURGE_TOKEN || ''
}
}
);
if (stderr && !stderr.includes('Success')) {
console.error('⚠️ Surge警告:', stderr);
}
const deployedUrl = `https://${domain}`;
console.log('✅ デプロイ完了!');
console.log(`🌐 URL: ${deployedUrl}`);
// URLをファイルに保存他のスクリプトから参照用
const dataDir = path.join(__dirname, '..', 'data');
await fs.mkdir(dataDir, { recursive: true });
const urlPath = path.join(dataDir, 'deployed-url.txt');
await fs.writeFile(urlPath, deployedUrl, 'utf-8');
return deployedUrl;
} catch (error) {
console.error('❌ デプロイエラー:', error.message);
if (error.message.includes('surge: command not found') || error.message.includes('surge')) {
console.log('💡 npx surge が実行できません。npm ci を再実行してください。');
}
// 認証エラーの場合
if (error.message.includes('Not authenticated') || error.message.includes('Invalid token')) {
console.log('\n💡 Surge認証が必要です:');
console.log('1. npx surge login でログイン');
console.log('2. または環境変数 SURGE_TOKEN を設定');
console.log(' トークン取得: npx surge token');
}
process.exit(1);
}
}
// 実行
if (import.meta.url === `file://${process.argv[1]}`) {
deployToSurge();
}
export default deployToSurge;

382
scripts/fetch-data.js Normal file
View File

@ -0,0 +1,382 @@
#!/usr/bin/env node
/**
* Fetch Linear data with sub-issues
*
* Fetches magazine parent issues and their sub-issues from Linear API.
* Parent issues are filtered by status labels (1.企画案ストック, 2.構成作成中, 3.原稿執筆中, 4.動画編集中).
* Sub-issues include status labels for process determination.
*/
import 'dotenv/config';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { LINEAR_API_URL, LINEAR_TEAM_KEY, LABEL_GROUPS, STATUS_LABELS } from '../config/settings.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const LINEAR_API_KEY = process.env.MAGAZINE_LINEAR_API_KEY;
/**
* Fetch magazines with sub-issues from Linear
*/
async function fetchLinearData() {
console.log('📊 Linearからマガジンデータ+サブイシューを取得中...');
if (!LINEAR_API_KEY) {
console.error('❌ MAGAZINE_LINEAR_API_KEY が設定されていません');
console.log('💡 環境変数 MAGAZINE_LINEAR_API_KEY=lin_api_xxxxx を設定してください');
process.exit(1);
}
// Calculate date for 1 month ago
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
// GraphQL query to fetch active (in-progress) parent issues with sub-issues
const activeQuery = `
query GetActiveMagazines {
issues(
first: 200,
filter: {
team: {
key: { eq: "${LINEAR_TEAM_KEY}" }
},
state: {
type: { in: ["backlog", "unstarted", "started"] }
},
parent: {
null: true
}
}
) {
nodes {
id
identifier
title
url
state {
id
name
type
}
labels {
nodes {
id
name
color
parent {
id
name
}
}
}
assignee {
id
name
displayName
}
dueDate
createdAt
updatedAt
children {
nodes {
id
identifier
title
url
state {
id
name
type
}
labels {
nodes {
id
name
color
parent {
id
name
}
}
}
dueDate
completedAt
createdAt
updatedAt
}
}
}
}
}
`;
// GraphQL query to fetch completed parent issues (within last month)
const completedQuery = `
query GetCompletedMagazines {
issues(
first: 200,
filter: {
team: {
key: { eq: "${LINEAR_TEAM_KEY}" }
},
state: {
type: { eq: "completed" }
},
parent: {
null: true
},
completedAt: {
gte: "${oneMonthAgo.toISOString()}"
}
}
) {
nodes {
id
identifier
title
url
state {
id
name
type
}
labels {
nodes {
id
name
color
parent {
id
name
}
}
}
assignee {
id
name
displayName
}
dueDate
createdAt
updatedAt
completedAt
children {
nodes {
id
identifier
title
url
state {
id
name
type
}
labels {
nodes {
id
name
color
parent {
id
name
}
}
}
dueDate
completedAt
createdAt
updatedAt
}
}
}
}
}
`;
try {
// Fetch active magazines
const activeResponse = await fetch(LINEAR_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': LINEAR_API_KEY
},
body: JSON.stringify({ query: activeQuery })
});
if (!activeResponse.ok) {
throw new Error(`Linear API エラー: ${activeResponse.status} ${activeResponse.statusText}`);
}
const activeData = await activeResponse.json();
if (activeData.errors) {
console.error('GraphQL エラー:', activeData.errors);
throw new Error('Linear APIからデータ取得に失敗しました');
}
const activeIssues = activeData.data.issues.nodes;
console.log(`${activeIssues.length}件の未完了親イシューを取得しました`);
// Fetch completed magazines
const completedResponse = await fetch(LINEAR_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': LINEAR_API_KEY
},
body: JSON.stringify({ query: completedQuery })
});
if (!completedResponse.ok) {
throw new Error(`Linear API エラー: ${completedResponse.status} ${completedResponse.statusText}`);
}
const completedData = await completedResponse.json();
if (completedData.errors) {
console.error('GraphQL エラー:', completedData.errors);
throw new Error('Linear APIからデータ取得に失敗しました');
}
const completedIssues = completedData.data.issues.nodes;
console.log(`${completedIssues.length}件の1ヶ月以内に完了した親イシューを取得しました`);
// Merge active and completed issues
const allIssues = [...activeIssues, ...completedIssues];
console.log(`✅ 合計 ${allIssues.length}件の親イシューを取得しました`);
// Filter issues by magazine status label group
const magazines = allIssues.filter(issue => {
let hasStatusLabel = false;
let hasStockStatus = false;
for (const label of issue.labels.nodes) {
if (label.parent && label.parent.name === LABEL_GROUPS.parentStatus) {
hasStatusLabel = true;
if (label.name === STATUS_LABELS.stock) {
hasStockStatus = true;
// ストックラベルが見つかった時点でこれ以上の走査は不要
break;
}
}
}
if (!hasStatusLabel) {
return false;
}
if (issue.state?.type === 'backlog') {
// Backlog は「1.企画案ストック」ラベルが付いているもののみ残す
return hasStockStatus;
}
return true;
});
console.log(`${magazines.length}件のマガジンイシューをフィルタしました`);
// Count sub-issues
const totalSubIssues = magazines.reduce((sum, mag) => sum + (mag.children?.nodes?.length || 0), 0);
console.log(`✅ 合計 ${totalSubIssues}件のサブイシューを取得しました`);
// Transform data structure
const transformedMagazines = magazines.map(issue => {
// Extract magazine status label (from the label group)
const statusLabel = issue.labels.nodes.find(label =>
label.parent && label.parent.name === LABEL_GROUPS.parentStatus
);
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
url: issue.url,
assignee: issue.assignee ? {
id: issue.assignee.id,
name: issue.assignee.displayName || issue.assignee.name
} : null,
dueDate: issue.dueDate,
label: statusLabel?.name || null,
state: {
id: issue.state.id,
name: issue.state.name,
type: issue.state.type
},
subIssues: (issue.children?.nodes || []).map(sub => ({
id: sub.id,
identifier: sub.identifier,
title: sub.title,
url: sub.url,
dueDate: sub.dueDate,
completedAt: sub.completedAt || null,
state: {
id: sub.state.id,
name: sub.state.name,
type: sub.state.type
},
labels: sub.labels.nodes.map(label => ({
id: label.id,
name: label.name,
color: label.color,
parent: label.parent ? {
id: label.parent.id,
name: label.parent.name
} : null
})),
createdAt: sub.createdAt,
updatedAt: sub.updatedAt
})),
createdAt: issue.createdAt,
updatedAt: issue.updatedAt
};
});
// Prepare result
const result = {
fetchedAt: new Date().toISOString(),
totalCount: transformedMagazines.length,
magazines: transformedMagazines,
summary: {
'1.企画案ストック': transformedMagazines.filter(m => m.label === STATUS_LABELS.stock).length,
'2.構成作成中': transformedMagazines.filter(m => m.label === STATUS_LABELS.composition).length,
'3.原稿執筆中': transformedMagazines.filter(m => m.label === STATUS_LABELS.manuscript).length,
'4.動画編集中': transformedMagazines.filter(m => m.label === STATUS_LABELS.video).length
}
};
// Save to file
const outputPath = path.join(__dirname, '..', 'data', 'linear-data.json');
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, JSON.stringify(result, null, 2));
console.log('💾 データを data/linear-data.json に保存しました');
// Display summary
console.log('\n📈 マガジンステータス別サマリー:');
console.log(` 1.企画案ストック: ${result.summary['1.企画案ストック']}`);
console.log(` 2.構成作成中: ${result.summary['2.構成作成中']}`);
console.log(` 3.原稿執筆中: ${result.summary['3.原稿執筆中']}`);
console.log(` 4.動画編集中: ${result.summary['4.動画編集中']}`);
console.log(` 合計: ${result.totalCount}\n`);
return result;
} catch (error) {
console.error('❌ エラーが発生しました:', error.message);
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
}
}
// Execute if run directly
if (import.meta.url === `file://${process.argv[1]}`) {
fetchLinearData();
}
export default fetchLinearData;

View File

@ -0,0 +1,271 @@
#!/usr/bin/env node
import 'dotenv/config';
import { GoogleGenerativeAI } from '@google/generative-ai';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { STATUS_LABELS } from '../config/settings.js';
import { generateFallbackSuggestions } from './ai-suggestion-helpers.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function generateAISuggestions() {
console.log('🤖 Gemini APIでAI提案を生成中...');
// API Key確認
const apiKey = process.env.MAGAZINE_GEMINI_API_KEY;
let healthData;
try {
const healthDataPath = path.join(__dirname, '..', 'data', 'health-data.json');
healthData = JSON.parse(await fs.readFile(healthDataPath, 'utf-8'));
} catch (error) {
console.error('❌ 健康度データの読み込みに失敗しました:', error.message);
console.log('💡 先に npm run calculate-health を実行してください');
process.exit(1);
}
console.log('📊 健康度データ読み込み完了:', {
企画案ストック: healthData.planStockHealth?.stockCount ?? 0,
構成作成中: healthData.summary['2.構成作成中'] ?? 0,
原稿執筆中: healthData.summary['3.原稿執筆中'] ?? 0,
動画編集中: healthData.summary['4.動画編集中'] ?? 0,
原稿執筆健康度: healthData.manuscriptHealth?.status,
動画編集健康度: healthData.videoHealth?.status
});
let suggestions;
let source = 'gemini';
let modelName = null;
let fallbackReason = null;
if (!apiKey) {
console.warn('⚠️ MAGAZINE_GEMINI_API_KEY が設定されていません。フォールバック提案を生成します。');
suggestions = generateFallbackSuggestions(healthData);
source = 'fallback';
fallbackReason = 'APIキー未設定';
} else {
try {
const promptPath = path.join(__dirname, '..', 'config', 'ai-prompts', 'unified.md');
const promptTemplate = await fs.readFile(promptPath, 'utf-8');
const context = buildContext(healthData);
const prompt = promptTemplate.replace('{{CONTEXT}}', context);
console.log('📝 プロンプト生成完了(文字数:', prompt.length, '');
const genAI = new GoogleGenerativeAI(apiKey);
const preferredModel = process.env.MAGAZINE_GEMINI_MODEL;
const modelCandidates = [];
if (preferredModel) {
modelCandidates.push(preferredModel);
}
const defaultModels = [
'models/gemini-3-flash-preview',
'models/gemini-2.5-flash',
];
defaultModels.forEach(model => {
if (!modelCandidates.includes(model)) {
modelCandidates.push(model);
}
});
let model;
let lastError;
for (const candidate of modelCandidates) {
try {
modelName = candidate;
console.log(`📦 モデル候補を初期化: ${modelName}`);
model = genAI.getGenerativeModel({ model: modelName });
console.log('🚀 Gemini API にリクエスト送信中...');
const result = await model.generateContent(prompt);
const response = await result.response;
const text = response.text();
console.log('✅ Geminiからレスポンスを受信文字数:', text.length, '');
const parsedSuggestions = parseAISuggestions(text);
if (!Array.isArray(parsedSuggestions) || parsedSuggestions.length !== 3) {
throw new Error(`AI提案の形式が不正です。3つの提案が必要ですが、${parsedSuggestions?.length ?? 0}個でした。`);
}
suggestions = parsedSuggestions;
lastError = null;
break;
} catch (error) {
console.warn(`⚠️ モデル ${modelName} の呼び出しに失敗しました: ${error.message}`);
lastError = error;
model = null;
}
}
if (!suggestions) {
throw lastError || new Error('利用可能なGeminiモデルでの生成に失敗しました');
}
} catch (error) {
console.error('⚠️ Gemini APIの呼び出しに失敗しました:', error.message);
if (error.stack) {
console.error('スタックトレース:', error.stack);
}
suggestions = generateFallbackSuggestions(healthData);
source = 'fallback';
fallbackReason = error.message;
}
}
if (!Array.isArray(suggestions) || suggestions.length !== 3) {
console.warn('⚠️ フォールバック生成の結果が不足していたため、デフォルト提案を再生成します。');
suggestions = generateFallbackSuggestions(healthData);
source = 'fallback';
fallbackReason = fallbackReason || 'フォールバック再生成';
}
const outputDir = path.join(__dirname, '..', 'data');
await fs.mkdir(outputDir, { recursive: true });
const outputData = {
generatedAt: new Date().toISOString(),
source,
model: source === 'gemini' ? modelName : null,
note: fallbackReason || undefined,
suggestions
};
const outputPath = path.join(outputDir, 'ai-suggestions.json');
await fs.writeFile(outputPath, JSON.stringify(outputData, null, 2), 'utf-8');
if (source === 'fallback') {
console.log('✅ フォールバック提案を保存しました:', outputPath);
if (fallbackReason) {
console.log(' フォールバック理由:', fallbackReason);
}
} else {
console.log('✅ Gemini生成のAI提案を保存しました:', outputPath);
}
console.log('\n🎯 生成されたAI提案:');
suggestions.forEach((suggestion, index) => {
console.log(`\n${index + 1}. ${suggestion.priorityLabel}`);
console.log(` 問題: ${suggestion.problem}`);
console.log(` アクション:\n${suggestion.action.split('\n').map(line => ` ${line}`).join('\n')}`);
});
console.log('');
return outputPath;
}
/**
* 健康度データから AI用のコンテキストを生成
*/
function buildContext(healthData) {
const lines = [];
const planStock = healthData.planStockHealth || {};
const composition = healthData.compositionHealth || {};
lines.push('### 企画案ストック');
lines.push(`- **ストック数**: ${planStock.stockCount ?? 0}`);
lines.push(`- **今週の新規追加**: ${planStock.weeklyNewCount ?? 0} / ${planStock.weeklyTarget ?? 2}`);
lines.push(`- **健康度**: ${planStock.status} ${planStock.label}`);
if (planStock.shortReason) {
lines.push(`- **詳細**: ${planStock.shortReason}`);
}
lines.push('');
lines.push('### 構成作成');
lines.push(`- **完了数**: ${composition.completedCount ?? 0} / ${composition.target ?? 3}`);
lines.push(`- **健康度**: ${composition.status} ${composition.label}`);
if (composition.shortReason) {
lines.push(`- **詳細**: ${composition.shortReason}`);
}
lines.push('');
lines.push('### 原稿執筆中のマガジン');
const manuscriptCount = healthData.summary?.['3.原稿執筆中'] || 0;
lines.push(`- **件数**: ${manuscriptCount}`);
lines.push(`- **健康度**: ${healthData.manuscriptHealth?.status} ${healthData.manuscriptHealth?.label}`);
if (healthData.manuscriptHealth?.details) {
lines.push(`- **詳細**: ${healthData.manuscriptHealth.details}`);
}
const manuscripts = healthData.magazines.filter(m =>
m.label === STATUS_LABELS.manuscript && m.state?.type !== 'completed'
);
if (manuscripts.length > 0) {
lines.push('- **マガジン一覧**:');
manuscripts.forEach(mag => {
const statusInfo = mag.displayHealthStatus?.message || '期限内';
lines.push(` - 【${mag.title}${mag.displayHealthStatus?.status || '🟢'} ${statusInfo}`);
});
}
lines.push('');
lines.push('### 動画編集中のマガジン');
const videoCount = healthData.summary?.['4.動画編集中'] || 0;
lines.push(`- **件数**: ${videoCount}`);
lines.push(`- **健康度**: ${healthData.videoHealth?.status} ${healthData.videoHealth?.label}`);
if (healthData.videoHealth?.details) {
lines.push(`- **詳細**: ${healthData.videoHealth.details}`);
}
const videos = healthData.magazines.filter(m =>
m.label === STATUS_LABELS.video && m.state?.type !== 'completed'
);
if (videos.length > 0) {
lines.push('- **マガジン一覧**:');
videos.forEach(mag => {
const statusInfo = mag.displayHealthStatus?.message || '期限内';
lines.push(` - 【${mag.title}${mag.displayHealthStatus?.status || '🟢'} ${statusInfo}`);
});
}
lines.push('');
lines.push('### その他の統計');
lines.push(`- **全マガジン数**: ${healthData.magazines.length}`);
lines.push(`- **生成日時**: ${new Date(healthData.calculatedAt).toLocaleString('ja-JP')}`);
return lines.join('\n');
}
/**
* Geminiのレスポンスから JSON を抽出してパース
*/
function parseAISuggestions(text) {
// コードブロックを除去
let jsonText = text.trim();
// ```json ... ``` または ``` ... ``` を除去
jsonText = jsonText.replace(/^```json?\s*\n?/gm, '');
jsonText = jsonText.replace(/\n?```\s*$/gm, '');
// 余計な前後のテキストを除去JSON配列の開始/終了を見つける)
const jsonStart = jsonText.indexOf('[');
const jsonEnd = jsonText.lastIndexOf(']');
if (jsonStart === -1 || jsonEnd === -1) {
throw new Error('JSONが見つかりませんでした。レスポンス: ' + text.substring(0, 500));
}
jsonText = jsonText.substring(jsonStart, jsonEnd + 1);
try {
const parsed = JSON.parse(jsonText);
return parsed;
} catch (error) {
console.error('JSON パースエラー:', error.message);
console.error('パース対象のテキスト:', jsonText.substring(0, 1000));
throw new Error('JSONのパースに失敗しました: ' + error.message);
}
}
// 実行
if (import.meta.url === `file://${process.argv[1]}`) {
generateAISuggestions();
}
export default generateAISuggestions;

View File

@ -0,0 +1,657 @@
#!/usr/bin/env node
/**
* Generate HTML dashboard from health data
*
* Takes health-data.json and generates an HTML dashboard with:
* - Health indicator section (overall + categories)
* - Magazine status section (manuscript + video columns)
* - Calendar section (2 weeks before/after)
*/
import 'dotenv/config';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { convertAssigneeName, convertTitleEmoji } from '../config/mappings.js';
import { LABEL_GROUPS, STATUS_LABELS } from '../config/settings.js';
import { generateFallbackSuggestions } from './ai-suggestion-helpers.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Format date as YYYY/MM/DD
*/
function formatDate(dateStr) {
const date = new Date(dateStr);
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}/${month}/${day}`;
}
/**
* Format date as M/D
*/
function formatDateShort(date) {
const month = date.getMonth() + 1;
const day = date.getDate();
return `${month}/${day}`;
}
/**
* Generate health section HTML
*/
function generateHealthSection(healthData) {
const { overallHealth, planStockHealth, compositionHealth, manuscriptHealth, videoHealth, thresholds, magazines } = healthData;
const getHealthLabelClass = (label) => {
if (label === '順調') return 'good';
if (label === '注意') return 'warning';
if (label === '危険') return 'danger';
return '';
};
const manuscriptMagazines = magazines.filter(m => m.label === STATUS_LABELS.manuscript && m.state?.type !== 'completed');
const manuscriptOverdue = manuscriptMagazines.filter(m =>
(m.displayHealthStatus?.status === '🔴' || m.displayHealthStatus?.status === '🟡') &&
!m.displayHealthStatus?.isDeadlineNotSet
).length;
const manuscriptDeadlineNotSet = manuscriptMagazines.filter(m => m.displayHealthStatus?.isDeadlineNotSet).length;
const manuscriptOnTime = manuscriptMagazines.filter(m => m.displayHealthStatus?.status === '🟢').length;
const videoMagazines = magazines.filter(m => m.label === STATUS_LABELS.video && m.state?.type !== 'completed');
const videoOverdue = videoMagazines.filter(m =>
(m.displayHealthStatus?.status === '🔴' || m.displayHealthStatus?.status === '🟡') &&
!m.displayHealthStatus?.isDeadlineNotSet
).length;
const videoDeadlineNotSet = videoMagazines.filter(m => m.displayHealthStatus?.isDeadlineNotSet).length;
const videoOnTime = videoMagazines.filter(m => m.displayHealthStatus?.status === '🟢').length;
const manuscriptDetailParts = [];
if (manuscriptOverdue > 0) manuscriptDetailParts.push(`期限切れ:${manuscriptOverdue}`);
if (manuscriptDeadlineNotSet > 0) manuscriptDetailParts.push(`期限未設定:${manuscriptDeadlineNotSet}`);
manuscriptDetailParts.push(`期限内:${manuscriptOnTime}`);
const manuscriptDetailText = '' + manuscriptDetailParts.join(' ') + '';
const videoDetailParts = [];
if (videoOverdue > 0) videoDetailParts.push(`期限切れ:${videoOverdue}`);
if (videoDeadlineNotSet > 0) videoDetailParts.push(`期限未設定:${videoDeadlineNotSet}`);
videoDetailParts.push(`期限内:${videoOnTime}`);
const videoDetailText = '' + videoDetailParts.join(' ') + '';
const overallDetailsHtml = (overallHealth.details || '').replace(/\n/g, '<br>');
return `
<!-- 1. 進捗健康度最上部 -->
<div class="health-status">
<h2>💊 進捗健康度</h2>
<!-- 全体の健康状態 -->
<div class="health-overall">
<h3>全体</h3>
<div class="health-indicator">${overallHealth.status}</div>
<div class="health-label ${getHealthLabelClass(overallHealth.label)}">${overallHealth.label}</div>
<div class="health-detail">
${overallDetailsHtml}
</div>
</div>
<!-- カテゴリ別の健康状態 (2x2) -->
<div class="health-grid">
<div class="health-card">
<h3>企画案ストック</h3>
<div class="health-indicator">${planStockHealth.status}</div>
<div class="health-label ${getHealthLabelClass(planStockHealth.label)}">${planStockHealth.label}</div>
<div class="health-detail">
今週の新規追加${planStockHealth.weeklyNewCount ?? 0} / ${planStockHealth.weeklyTarget ?? 2}<br>
ストック数${planStockHealth.stockCount ?? 0}
</div>
</div>
<div class="health-card">
<h3>構成作成</h3>
<div class="health-indicator">${compositionHealth.status}</div>
<div class="health-label ${getHealthLabelClass(compositionHealth.label)}">${compositionHealth.label}</div>
<div class="health-detail">
${compositionHealth.details}<br>
<span style="font-size: 0.85em; color: #bbb;">${compositionHealth.cyclePeriod || ''}</span>
</div>
</div>
<div class="health-card">
<h3>原稿執筆</h3>
<div class="health-indicator">${manuscriptHealth.status}</div>
<div class="health-label ${getHealthLabelClass(manuscriptHealth.label)}">${manuscriptHealth.label}</div>
<div class="health-detail">
原稿執筆中${manuscriptMagazines.length}<br>
${manuscriptDetailText}
</div>
</div>
<div class="health-card">
<h3>動画編集</h3>
<div class="health-indicator">${videoHealth.status}</div>
<div class="health-label ${getHealthLabelClass(videoHealth.label)}">${videoHealth.label}</div>
<div class="health-detail">
動画編集中${videoMagazines.length}<br>
${videoDetailText}
</div>
</div>
</div>
<!-- 判断基準原稿動画のみ -->
<div class="health-criteria">
<div style="text-align: center;">
<div style="font-weight: 600; color: #666; margin-bottom: 8px;">原稿動画</div>
<div style="display: flex; gap: 15px; justify-content: center; font-size: 0.9em;">
<span><span style="font-size: 1.2em;">🟢</span> </span>
<span><span style="font-size: 1.2em;">🟡</span> ${thresholds.delay.warning}</span>
<span><span style="font-size: 1.2em;">🔴</span> ${thresholds.delay.warning + 1}</span>
</div>
</div>
</div>
</div>
`;
}
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatActions(actionText) {
if (!actionText) {
return '';
}
const items = actionText
.split('\n')
.map(line => line.replace(/^•\s*/, '').trim())
.filter(Boolean);
if (items.length === 0) {
return '';
}
const listItems = items.map(item => `<li>${escapeHtml(item)}</li>`).join('');
return `
<div class="suggestion-action">
<strong>推奨アクション</strong>
<ul>${listItems}</ul>
</div>
`;
}
function generateAISuggestionsSection(aiInfo) {
const suggestions = Array.isArray(aiInfo?.suggestions) ? aiInfo.suggestions : [];
if (suggestions.length === 0) {
return `
<div class="ai-suggestions">
<h2>AIからの提案</h2>
<div class="ai-suggestions-note">AI提案データが見つからなかったため健康度データから推奨事項を生成できませんでした</div>
</div>
`;
}
const priorityIcons = {
high: '🔥',
medium: '🧭',
low: '🌱'
};
const generatedAt = aiInfo?.generatedAt
? new Date(aiInfo.generatedAt).toLocaleString('ja-JP', {
timeZone: 'Asia/Tokyo',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: '取得日時不明';
const sourceLabel = aiInfo?.source === 'gemini'
? `Gemini${aiInfo?.model ? ` (${aiInfo.model})` : ''} で生成`
: '健康度データから自動生成';
const note = aiInfo?.note ? escapeHtml(String(aiInfo.note).slice(0, 160) + (String(aiInfo.note).length > 160 ? '…' : '')) : '';
const itemsHTML = suggestions.map(suggestion => {
const priority = suggestion.priority || 'info';
const icon = priorityIcons[priority] || '💡';
const priorityClass = `priority-${priority}`;
const problem = escapeHtml(suggestion.problem || '状況説明なし');
const actions = formatActions(suggestion.action);
const label = escapeHtml(suggestion.priorityLabel || '優先度:情報');
return `
<div class="suggestion-item">
<div class="suggestion-icon">${icon}</div>
<div class="suggestion-content">
<span class="suggestion-priority ${priorityClass}">${label}</span>
<h3>${problem}</h3>
${actions}
</div>
</div>
`;
}).join('');
return `
<!-- 2. AIサジェスト -->
<div class="ai-suggestions">
<h2>AIからの提案</h2>
<div class="ai-suggestions-meta">
<span>${escapeHtml(sourceLabel)}</span>
</div>
${note ? `<div class="ai-suggestions-note">${note}</div>` : ''}
${itemsHTML}
</div>
`;
}
/**
* Generate magazine status section
*/
function generateMagazineStatusSection(healthData) {
const { magazines } = healthData;
// Filter by category (exclude completed magazines)
const manuscriptMagazines = magazines.filter(m =>
m.label === STATUS_LABELS.manuscript && m.state?.type !== 'completed'
);
const videoMagazines = magazines.filter(m =>
m.label === STATUS_LABELS.video && m.state?.type !== 'completed'
);
// Sort by publish date (dueDate) ascending, with null values at the end
const sortByPublishDate = (a, b) => {
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1; // a is null, move to end
if (!b.dueDate) return -1; // b is null, move to end
return new Date(a.dueDate) - new Date(b.dueDate); // ascending order
};
manuscriptMagazines.sort(sortByPublishDate);
videoMagazines.sort(sortByPublishDate);
// Generate task items (uses pre-calculated displayHealthStatus from health-data.json)
const generateTaskItems = (magazineList, columnType) => {
if (magazineList.length === 0) {
return '<li class="task-item"><div class="task-title">進行中のタスクなし</div></li>';
}
// Helper function to determine badge class from status
const getBadgeClass = (status) => {
if (status === '🔴') return 'overdue';
if (status === '🟡') return 'warning';
return 'good';
};
return magazineList.map(magazine => {
const { title, assignee, currentProcesses, dueDate: publishDate, subIssues, displayHealthStatus } = magazine;
// Generate delay info from displayHealthStatus (already calculated in calculate-health.js)
const delayInfo = displayHealthStatus?.message
? `<div class="task-detail">${displayHealthStatus.message}</div>`
: '';
let dueInfoHTML = '';
if (columnType === 'manuscript') {
// 原稿側: 原稿期限 + 公開日
// Get manuscript due date (2.原稿) for display
const manuscriptSubs = subIssues.filter(sub =>
sub.labels && sub.labels.some(label =>
label.parent &&
label.parent.name === LABEL_GROUPS.subIssueStatus &&
label.name.includes('原稿')
)
);
let manuscriptDueDate = null;
if (manuscriptSubs.length > 0) {
const subsWithDue = manuscriptSubs.filter(sub => sub.dueDate);
if (subsWithDue.length > 0) {
const earliestSub = subsWithDue.reduce((earliest, sub) =>
new Date(sub.dueDate) < new Date(earliest.dueDate) ? sub : earliest
);
manuscriptDueDate = earliestSub.dueDate;
}
}
const manuscriptDueStr = manuscriptDueDate
? formatDateShort(new Date(manuscriptDueDate))
: '未設定';
const publishDueStr = publishDate ? formatDateShort(new Date(publishDate)) : '未設定';
// Badge colors based on displayHealthStatus
const badgeClass = getBadgeClass(displayHealthStatus?.status);
const manuscriptDueClass = !manuscriptDueDate ? 'overdue' : badgeClass;
const publishDueClass = !publishDate ? 'overdue' : badgeClass;
dueInfoHTML = `
<span class="task-due ${manuscriptDueClass}"><span class="due-label">原稿期限:</span>${manuscriptDueStr}</span>
<span class="task-due ${publishDueClass}"><span class="due-label">公開日:</span>${publishDueStr}</span>
`;
} else if (columnType === 'video') {
// 動画側: 公開日のみ
const publishDueStr = publishDate ? formatDateShort(new Date(publishDate)) : '未設定';
// Badge color based on displayHealthStatus
const publishDueClass = !publishDate ? 'overdue' : getBadgeClass(displayHealthStatus?.status);
dueInfoHTML = `<span class="task-due ${publishDueClass}"><span class="due-label">公開日:</span>${publishDueStr}</span>`;
}
// Generate status label badges (only if labels exist)
const statusLabelsHTML = currentProcesses && currentProcesses.length > 0
? currentProcesses.map(label =>
`<span class="task-current-process">${label}</span>`
).join('')
: '';
return `
<li class="task-item">
<div class="task-title">${convertTitleEmoji(title)}</div>
<div class="task-meta">
<span class="task-assignee">${convertAssigneeName(assignee?.name)}</span>
${statusLabelsHTML}
${dueInfoHTML}
</div>
${delayInfo}
</li>
`;
}).join('');
};
return `
<!-- 3. マガジン別ステータス -->
<div class="details-section">
<h2>📋 マガジン別ステータス</h2>
<!-- 凡例 -->
<div class="status-legend">
<div class="legend-item">
<div class="legend-dot good"></div>
<span class="legend-label">期限内</span>
</div>
<div class="legend-item">
<div class="legend-dot warning"></div>
<span class="legend-label">制作過程の遅延</span>
</div>
<div class="legend-item">
<div class="legend-dot overdue"></div>
<span class="legend-label">期限切れ</span>
</div>
</div>
<div class="process-grid">
<!-- 原稿側 -->
<div class="process-section">
<h3>📝 原稿側 <span style="font-size: 0.6em; color: #666; font-weight: normal;">(${manuscriptMagazines.length})</span></h3>
<div class="status-group">
<ul class="task-list">
${generateTaskItems(manuscriptMagazines, 'manuscript')}
</ul>
</div>
</div>
<!-- 動画側 -->
<div class="process-section">
<h3>🎬 動画側 <span style="font-size: 0.6em; color: #666; font-weight: normal;">(${videoMagazines.length})</span></h3>
<div class="status-group">
<ul class="task-list">
${generateTaskItems(videoMagazines, 'video')}
</ul>
</div>
</div>
</div>
</div>
`;
}
/**
* Generate calendar section
*/
function generateCalendarSection(healthData) {
const { magazines } = healthData;
const today = new Date();
today.setHours(0, 0, 0, 0);
// Calculate date range: 2 weeks before + this week + 2 weeks after (5 weeks total)
const startDate = new Date(today);
startDate.setDate(today.getDate() - 14 - today.getDay()); // Start from Sunday 2 weeks ago
const endDate = new Date(startDate);
endDate.setDate(startDate.getDate() + 35); // 5 weeks = 35 days
// Generate calendar days
const calendarDays = [];
const currentDate = new Date(startDate);
while (currentDate < endDate) {
calendarDays.push(new Date(currentDate));
currentDate.setDate(currentDate.getDate() + 1);
}
// Map magazines to their due dates (use parent issue dueDate)
const magazinesByDate = {};
magazines.forEach(magazine => {
if (magazine.dueDate) {
const dueDate = new Date(magazine.dueDate);
dueDate.setHours(0, 0, 0, 0);
const dateKey = dueDate.toISOString().split('T')[0];
if (!magazinesByDate[dateKey]) {
magazinesByDate[dateKey] = [];
}
// Determine phase for coloring
let phase = 'manuscript';
if (magazine.label === '4.動画編集中') {
phase = 'video';
}
// Check if already published
// 1. Parent issue is completed
// 2. All sub-issues are Done
const parentCompleted = magazine.state?.type === 'completed';
const allSubsDone = magazine.subIssues && magazine.subIssues.length > 0
? magazine.subIssues.every(s => s.state.name === 'Done' || s.state.type === 'completed')
: false;
if (parentCompleted || allSubsDone) {
phase = 'published';
}
magazinesByDate[dateKey].push({
title: convertTitleEmoji(magazine.title),
phase,
magazine
});
}
});
// Generate calendar grid HTML
const calendarDaysHTML = calendarDays.map(date => {
const dateKey = date.toISOString().split('T')[0];
const isToday = date.getTime() === today.getTime();
const tasksOnThisDay = magazinesByDate[dateKey] || [];
const tasksHTML = tasksOnThisDay.map(task =>
`<div class="calendar-task phase-${task.phase}">${task.title}</div>`
).join('');
const todayLabel = isToday ? '<div style="font-weight: bold; color: #D60C52; font-size: 0.75em;">今日</div>' : '';
return `
<div class="calendar-day${isToday ? ' today' : ''}">
<div class="calendar-day-number">${formatDateShort(date)}</div>
${todayLabel}
${tasksHTML}
</div>
`;
}).join('');
return `
<!-- 4. カレンダー -->
<div class="calendar-section">
<h2>📅 公開スケジュールカレンダー前後2週間</h2>
<!-- カレンダー凡例 -->
<div class="calendar-legend">
<div class="calendar-legend-item">
<div class="calendar-legend-box manuscript"></div>
<span class="calendar-legend-label">原稿執筆中</span>
</div>
<div class="calendar-legend-item">
<div class="calendar-legend-box video"></div>
<span class="calendar-legend-label">動画制作中</span>
</div>
<div class="calendar-legend-item">
<div class="calendar-legend-box published"></div>
<span class="calendar-legend-label">公開済み</span>
</div>
</div>
<div class="calendar-grid">
<!-- 曜日ヘッダー -->
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
${calendarDaysHTML}
</div>
</div>
`;
}
async function loadAISuggestions(healthData) {
const aiDataPath = path.join(__dirname, '..', 'data', 'ai-suggestions.json');
try {
const content = await fs.readFile(aiDataPath, 'utf-8');
const parsed = JSON.parse(content);
if (!Array.isArray(parsed.suggestions) || parsed.suggestions.length !== 3) {
throw new Error('AI提案が3件揃っていません');
}
return parsed;
} catch (error) {
console.warn('⚠️ ai-suggestions.json の読み込みに失敗しました。フォールバック提案を使用します。', error.message);
return {
generatedAt: new Date().toISOString(),
source: 'fallback',
model: null,
note: 'ai-suggestions.json が見つからないため、健康度データから再生成しました',
suggestions: generateFallbackSuggestions(healthData)
};
}
}
/**
* Generate complete HTML dashboard
*/
async function generateDashboard() {
console.log('📊 ダッシュボードHTMLを生成中...');
// Load health data
const dataPath = path.join(__dirname, '..', 'data', 'health-data.json');
let healthData;
try {
const dataContent = await fs.readFile(dataPath, 'utf-8');
healthData = JSON.parse(dataContent);
} catch (error) {
console.error('❌ health-data.json の読み込みに失敗しました:', error.message);
console.log('💡 先に npm run calculate-health を実行してください');
process.exit(1);
}
const updateTime = new Date(healthData.calculatedAt).toLocaleString('ja-JP', {
timeZone: 'Asia/Tokyo',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
// Read styles from config file
const stylesPath = path.join(__dirname, '..', 'config', 'dashboard-styles.css');
let styles = await fs.readFile(stylesPath, 'utf-8');
// Generate HTML sections
const healthSection = generateHealthSection(healthData);
const aiSuggestionsInfo = await loadAISuggestions(healthData);
const aiSuggestionsSection = generateAISuggestionsSection(aiSuggestionsInfo);
const magazineStatusSection = generateMagazineStatusSection(healthData);
const calendarSection = generateCalendarSection(healthData);
// Generate complete HTML
const html = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>マガジン進捗管理ダッシュボード</title>
<style>${styles}</style>
</head>
<body>
<div class="container">
<h1>📊 マガジン進捗管理ダッシュボード</h1>
<div class="subtitle">${updateTime} 更新</div>
${healthSection}
${aiSuggestionsSection}
${magazineStatusSection}
${calendarSection}
</div>
</body>
</html>
`;
// Save to output/dashboard.html
const outputDir = path.join(__dirname, '..', 'output');
await fs.mkdir(outputDir, { recursive: true });
const outputPath = path.join(outputDir, 'dashboard.html');
await fs.writeFile(outputPath, html);
console.log('✅ ダッシュボードを生成しました: output/dashboard.html');
console.log(`📊 データ更新日時: ${updateTime}`);
return { outputPath, healthData };
}
// Execute if run directly
if (import.meta.url === `file://${process.argv[1]}`) {
generateDashboard().catch(error => {
console.error('❌ エラーが発生しました:', error.message);
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
});
}
export default generateDashboard;

213
scripts/post-to-slack.js Normal file
View File

@ -0,0 +1,213 @@
#!/usr/bin/env node
/**
* Post dashboard screenshot to Slack
*
* Uploads dashboard screenshot to Slack with summary message
*
* Dry run: DRY_RUN=1, DRY_RUN=true, or --dry-run
* - Does not post. Prints payload summary.
* - If SLACK_BOT_TOKEN is set, calls auth.test only (no channel post).
*/
import 'dotenv/config';
import { WebClient } from '@slack/web-api';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function isDryRun() {
return (
process.env.DRY_RUN === '1' ||
process.env.DRY_RUN === 'true' ||
process.argv.includes('--dry-run')
);
}
/**
* Build Slack post payload from local files (no network).
*/
async function buildPostPayload() {
const dataPath = path.join(__dirname, '..', 'data', 'health-data.json');
const healthData = JSON.parse(await fs.readFile(dataPath, 'utf-8'));
const now = new Date(healthData.calculatedAt).toLocaleString('ja-JP', {
timeZone: 'Asia/Tokyo',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
let dashboardUrl = null;
try {
const urlPath = path.join(__dirname, '..', 'data', 'deployed-url.txt');
dashboardUrl = (await fs.readFile(urlPath, 'utf-8')).trim();
} catch {
// skip
}
let messageText = `📊 マガジン進捗管理ダッシュボード(${now}\n\n`;
if (dashboardUrl) {
messageText += `🔗 <${dashboardUrl}|ダッシュボードを開く>\n\n`;
}
messageText += `*全体健康度*: ${healthData.overallHealth.status} ${healthData.overallHealth.label}`;
const screenshotPaths = [
path.join(__dirname, '..', 'output', 'screenshot-1.png'),
path.join(__dirname, '..', 'output', 'screenshot-2.png'),
path.join(__dirname, '..', 'output', 'screenshot-3.png')
];
const fileUploads = [];
for (let i = 0; i < screenshotPaths.length; i++) {
try {
await fs.access(screenshotPaths[i]);
const imageBuffer = await fs.readFile(screenshotPaths[i]);
fileUploads.push({
file: imageBuffer,
filename: `magazine-dashboard-${i + 1}.png`,
path: screenshotPaths[i],
sizeBytes: imageBuffer.length
});
} catch {
// missing
}
}
return { messageText, dashboardUrl, fileUploads, healthData };
}
async function postToSlack() {
const dryRun = isDryRun();
if (dryRun) {
console.log('🧪 DRY RUN — Slack には投稿しません\n');
} else {
console.log('💬 Slackに投稿中...');
}
const channelId = process.env.MAGAZINE_SLACK_CHANNEL_ID;
if (!channelId) {
console.error('❌ MAGAZINE_SLACK_CHANNEL_ID が設定されていません');
console.log('💡 .env ファイルに MAGAZINE_SLACK_CHANNEL_ID=C0XXXXXXXXX を設定してください');
process.exit(1);
}
if (!dryRun && !process.env.MAGAZINE_SLACK_BOT_TOKEN) {
console.error('❌ MAGAZINE_SLACK_BOT_TOKEN が設定されていません');
console.log('💡 .env ファイルに MAGAZINE_SLACK_BOT_TOKEN=xoxb-xxxxx を設定してください');
process.exit(1);
}
console.log(`📮 投稿先チャンネル ID: ${channelId}`);
try {
const { messageText, dashboardUrl, fileUploads } = await buildPostPayload();
if (dryRun) {
console.log('--- 投稿予定の本文 ---');
console.log(messageText.replace(/<([^|>]+)\|([^>]+)>/g, '$2 ($1)'));
console.log('---');
console.log(`ダッシュボードURL: ${dashboardUrl ?? '(なし)'}`);
console.log(`添付画像: ${fileUploads.length}`);
for (const u of fileUploads) {
console.log(` - ${u.filename} (${u.sizeBytes} bytes)`);
}
if (fileUploads.length === 0) {
console.log(' (スクリーンショットなし → テキストのみ投稿の想定)');
}
if (process.env.MAGAZINE_SLACK_BOT_TOKEN) {
const slack = new WebClient(process.env.MAGAZINE_SLACK_BOT_TOKEN);
const auth = await slack.auth.test();
if (auth.ok) {
console.log('\n✅ auth.test OKトークンは有効');
console.log(` bot: ${auth.user ?? auth.bot_id ?? '—'} / team: ${auth.team ?? '—'}`);
} else {
console.log('\n⚠ auth.test 失敗:', auth.error);
process.exit(1);
}
} else {
console.log('\n💡 MAGAZINE_SLACK_BOT_TOKEN 未設定のため auth.test はスキップ');
}
console.log('\n✅ ドライラン完了');
return;
}
const slack = new WebClient(process.env.MAGAZINE_SLACK_BOT_TOKEN);
const uploadsForApi = fileUploads.map(({ file, filename }) => ({ file, filename }));
console.log(`📤 メッセージを投稿中... (画像: ${uploadsForApi.length}枚)`);
if (uploadsForApi.length > 0) {
const uploadResult = await slack.files.uploadV2({
channel_id: channelId,
file_uploads: uploadsForApi,
initial_comment: messageText
});
if (uploadResult.ok) {
console.log('✅ 画像アップロード完了');
console.log('✅ Slackへの投稿が完了しました!');
console.log(`📍 投稿先チャンネルID: ${channelId}`);
if (uploadResult.files && uploadResult.files.length > 0) {
const file = uploadResult.files[0];
if (file.files && file.files.length > 0 && file.files[0].permalink) {
console.log(`📎 ファイルURL: ${file.files[0].permalink}`);
}
}
return;
}
console.error('⚠️ アップロードエラー:', uploadResult.error);
process.exit(1);
}
console.log('⚠️ スクリーンショットが見つからないため、テキストのみ投稿します');
const postResult = await slack.chat.postMessage({
channel: channelId,
text: messageText
});
if (postResult.ok) {
console.log('✅ Slackへの投稿が完了しました! (テキストのみ)');
console.log(`📍 投稿先チャンネルID: ${channelId}`);
return;
}
console.error('⚠️ 投稿エラー:', postResult.error);
process.exit(1);
} catch (error) {
console.error('❌ エラーが発生しました:', error.message);
if (error.data?.error === 'not_in_channel') {
console.log('💡 ボットをチャンネルに追加してください:');
console.log(` /invite @your-bot-name をチャンネルで実行`);
} else if (error.data?.error === 'channel_not_found') {
console.log('💡 チャンネルが見つかりません。チャンネルIDを確認してください。');
console.log(' プライベートチャンネルの場合は、ボットを招待する必要があります。');
} else if (error.data?.error === 'invalid_auth') {
console.log('💡 Slack Bot Token が無効です。.env ファイルを確認してください。');
}
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
postToSlack();
}
export default postToSlack;

282
scripts/take-screenshot.js Normal file
View File

@ -0,0 +1,282 @@
#!/usr/bin/env node
/**
* Take screenshot of dashboard HTML
*
* Uses Playwright to capture full-page screenshot with Japanese font support.
* Can capture from deployed URL or local HTML file.
* Outputs 3 images (mobile-optimized vertical layout):
* - screenshot-1.png: Header + Health Status
* - screenshot-2.png: AI Suggestions
* - screenshot-3.png: Magazine Details + Calendar
*/
import 'dotenv/config';
import { chromium } from 'playwright';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function loadLocalHtml(page, localHtmlPath) {
await fs.access(localHtmlPath);
const localUrl = `file://${localHtmlPath}`;
console.log(`📂 ローカルHTML: ${localUrl}`);
await page.goto(localUrl, {
waitUntil: 'networkidle',
timeout: 60000
});
return localUrl;
}
async function takeScreenshot() {
console.log('📸 スクリーンショットを生成中...');
let browser;
try {
const urlPath = path.join(__dirname, '..', 'data', 'deployed-url.txt');
let url;
let usedDeployedUrl = false;
try {
url = await fs.readFile(urlPath, 'utf-8');
url = url.trim();
usedDeployedUrl = true;
url = `${url}?t=${Date.now()}`;
console.log(`🌐 対象URL: ${url}`);
} catch {
console.log('⚠️ デプロイURLが見つかりません。ローカルHTMLを使用します。');
const htmlPath = path.join(__dirname, '..', 'output', 'dashboard.html');
try {
await fs.access(htmlPath);
url = `file://${htmlPath}`;
} catch {
console.error('❌ HTMLファイルが見つかりません');
console.log('💡 先に npm run generate-dashboard を実行してください');
process.exit(1);
}
}
if (usedDeployedUrl && url.startsWith('http')) {
const waitMs = Number(process.env.MAGAZINE_SURGE_CDN_WAIT_MS ?? '10000');
if (waitMs > 0) {
console.log(`⏳ Surge CDN 反映待ち ${waitMs}ms...`);
await new Promise((r) => setTimeout(r, waitMs));
}
}
console.log('🌐 ブラウザを起動中...');
browser = await chromium.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--font-render-hinting=none',
'--lang=ja-JP'
]
});
const context = await browser.newContext({
viewport: { width: 1100, height: 2000 },
deviceScaleFactor: 2,
locale: 'ja-JP'
});
const page = await context.newPage();
await page.addInitScript(() => {
const style = document.createElement('style');
style.textContent = `
body {
font-family:
'Apple Color Emoji',
'Segoe UI Emoji',
'Noto Color Emoji',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
'Noto Sans JP',
'Noto Sans CJK JP',
'Hiragino Sans',
'Hiragino Kaku Gothic ProN',
'メイリオ',
Meiryo,
sans-serif;
}
`;
document.head.appendChild(style);
});
console.log('📄 ページを読み込み中...');
try {
await page.goto(url, {
waitUntil: 'networkidle',
timeout: 60000
});
console.log('✅ ページ読み込み成功');
} catch (error) {
console.warn(`⚠️ デプロイURLの読み込みに失敗しました: ${error.message}`);
console.log('🔄 ローカルHTMLにフォールバック中...');
const htmlPath = path.join(__dirname, '..', 'output', 'dashboard.html');
try {
url = await loadLocalHtml(page, htmlPath);
console.log('✅ ローカルHTMLの読み込み成功');
} catch (localError) {
console.error('❌ ローカルHTMLの読み込みにも失敗しました');
throw localError;
}
}
await page.waitForTimeout(2000);
const contentHeight = await page.evaluate(() => document.body.scrollHeight);
console.log(`📏 コンテンツ高さ: ${contentHeight}px`);
await page.setViewportSize({ width: 1100, height: contentHeight });
const screenshotDir = path.join(__dirname, '..', 'output');
await fs.mkdir(screenshotDir, { recursive: true });
let sections;
try {
sections = await page.evaluate(() => {
const healthStatus = document.querySelector('.health-status');
const aiSuggestions = document.querySelector('.ai-suggestions');
const detailsSection = document.querySelector('.details-section');
const calendarSection = document.querySelector('.calendar-section');
if (!healthStatus || !aiSuggestions || !detailsSection || !calendarSection) {
throw new Error('必要なセクションが見つかりません');
}
const aiRect = aiSuggestions.getBoundingClientRect();
const detailsRect = detailsSection.getBoundingClientRect();
const bodyRect = document.body.getBoundingClientRect();
const dividerOffset = 50;
const w = Math.round(bodyRect.width);
const aiTop = Math.round(aiRect.top - dividerOffset);
const detailsTop = Math.round(detailsRect.top - dividerOffset);
const bodyHeight = Math.round(bodyRect.height);
const h1 = aiTop;
const h2 = detailsTop - aiTop;
const h3 = bodyHeight - detailsTop;
const maxH = Math.max(h1, h2, h3);
return {
maxHeight: maxH,
screenshot1: { x: 0, y: 0, width: w, height: h1 },
screenshot2: { x: 0, y: aiTop, width: w, height: h2 },
screenshot3: { x: 0, y: detailsTop, width: w, height: h3 }
};
});
} catch (error) {
console.warn(`⚠️ セクション要素の取得に失敗しました: ${error.message}`);
console.log('🔄 ローカルHTMLにフォールバック中...');
const htmlPath = path.join(__dirname, '..', 'output', 'dashboard.html');
try {
url = await loadLocalHtml(page, htmlPath);
console.log('✅ ローカルHTMLの読み込み成功');
await page.waitForTimeout(2000);
const newContentHeight = await page.evaluate(() => document.body.scrollHeight);
await page.setViewportSize({ width: 1100, height: newContentHeight });
sections = await page.evaluate(() => {
const healthStatus = document.querySelector('.health-status');
const aiSuggestions = document.querySelector('.ai-suggestions');
const detailsSection = document.querySelector('.details-section');
const calendarSection = document.querySelector('.calendar-section');
if (!healthStatus || !aiSuggestions || !detailsSection || !calendarSection) {
throw new Error('必要なセクションが見つかりません');
}
const aiRect = aiSuggestions.getBoundingClientRect();
const detailsRect = detailsSection.getBoundingClientRect();
const bodyRect = document.body.getBoundingClientRect();
const dividerOffset = 50;
const w = Math.round(bodyRect.width);
const aiTop = Math.round(aiRect.top - dividerOffset);
const detailsTop = Math.round(detailsRect.top - dividerOffset);
const bodyHeight = Math.round(bodyRect.height);
const h1 = aiTop;
const h2 = detailsTop - aiTop;
const h3 = bodyHeight - detailsTop;
const maxH = Math.max(h1, h2, h3);
return {
maxHeight: maxH,
screenshot1: { x: 0, y: 0, width: w, height: h1 },
screenshot2: { x: 0, y: aiTop, width: w, height: h2 },
screenshot3: { x: 0, y: detailsTop, width: w, height: h3 }
};
});
console.log('✅ ローカルHTMLでセクション取得成功');
} catch (localError) {
console.error('❌ ローカルHTMLでのセクション取得にも失敗しました');
throw localError;
}
}
console.log('📐 セクション位置情報:', sections);
const maxH = sections.maxHeight;
const requiredHeight = sections.screenshot3.y + maxH;
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
if (requiredHeight > currentHeight) {
await page.setViewportSize({ width: 1100, height: requiredHeight });
await page.waitForTimeout(500);
}
const clips = [
{ ...sections.screenshot1, height: maxH },
{ ...sections.screenshot2, height: maxH },
{ ...sections.screenshot3, height: maxH }
];
console.log(`📏 画像サイズ統一: 全て ${clips[0].width} x ${maxH} (元の高さ: ${sections.screenshot1.height}, ${sections.screenshot2.height}, ${sections.screenshot3.height})`);
const screenshot1Path = path.join(screenshotDir, 'screenshot-1.png');
await page.screenshot({ path: screenshot1Path, clip: clips[0], type: 'png' });
console.log('✅ スクリーンショット1保存完了:', screenshot1Path);
const screenshot2Path = path.join(screenshotDir, 'screenshot-2.png');
await page.screenshot({ path: screenshot2Path, clip: clips[1], type: 'png' });
console.log('✅ スクリーンショット2保存完了:', screenshot2Path);
const screenshot3Path = path.join(screenshotDir, 'screenshot-3.png');
await page.screenshot({ path: screenshot3Path, clip: clips[2], type: 'png' });
console.log('✅ スクリーンショット3保存完了:', screenshot3Path);
return { screenshot1Path, screenshot2Path, screenshot3Path };
} catch (error) {
console.error('❌ スクリーンショットエラー:', error.message);
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
} finally {
if (browser) {
await browser.close();
}
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
takeScreenshot();
}
export default takeScreenshot;