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

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
+254
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
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
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
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;
+271
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;
+657
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
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
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;