#!/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, '
');
return `
💊 進捗健康度
全体
${overallHealth.status}
${overallHealth.label}
${overallDetailsHtml}
企画案ストック
${planStockHealth.status}
${planStockHealth.label}
今週の新規追加:${planStockHealth.weeklyNewCount ?? 0} / ${planStockHealth.weeklyTarget ?? 2}件
ストック数:${planStockHealth.stockCount ?? 0}件
構成作成
${compositionHealth.status}
${compositionHealth.label}
${compositionHealth.details}
${compositionHealth.cyclePeriod || ''}
原稿執筆
${manuscriptHealth.status}
${manuscriptHealth.label}
原稿執筆中:${manuscriptMagazines.length}件
${manuscriptDetailText}
動画編集
${videoHealth.status}
${videoHealth.label}
動画編集中:${videoMagazines.length}件
${videoDetailText}
原稿・動画
🟢 期限内
🟡 ${thresholds.delay.warning}日以内遅延
🔴 ${thresholds.delay.warning + 1}日以上遅延
`;
}
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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 => `${escapeHtml(item)}`).join('');
return `
`;
}
function generateAISuggestionsSection(aiInfo) {
const suggestions = Array.isArray(aiInfo?.suggestions) ? aiInfo.suggestions : [];
if (suggestions.length === 0) {
return `
AIからの提案
AI提案データが見つからなかったため、健康度データから推奨事項を生成できませんでした。
`;
}
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 `
${icon}
${label}
${problem}
${actions}
`;
}).join('');
return `
AIからの提案
${escapeHtml(sourceLabel)}
${note ? `
${note}
` : ''}
${itemsHTML}
`;
}
/**
* 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 '進行中のタスクなし
';
}
// 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
? `${displayHealthStatus.message}
`
: '';
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 = `
原稿期限:${manuscriptDueStr}
公開日:${publishDueStr}
`;
} else if (columnType === 'video') {
// 動画側: 公開日のみ
const publishDueStr = publishDate ? formatDateShort(new Date(publishDate)) : '未設定';
// Badge color based on displayHealthStatus
const publishDueClass = !publishDate ? 'overdue' : getBadgeClass(displayHealthStatus?.status);
dueInfoHTML = `公開日:${publishDueStr}`;
}
// Generate status label badges (only if labels exist)
const statusLabelsHTML = currentProcesses && currentProcesses.length > 0
? currentProcesses.map(label =>
`${label}`
).join('')
: '';
return `
${convertTitleEmoji(title)}
${convertAssigneeName(assignee?.name)}
${statusLabelsHTML}
${dueInfoHTML}
${delayInfo}
`;
}).join('');
};
return `
📋 マガジン別ステータス
📝 原稿側 (${manuscriptMagazines.length}件)
${generateTaskItems(manuscriptMagazines, 'manuscript')}
🎬 動画側 (${videoMagazines.length}件)
${generateTaskItems(videoMagazines, 'video')}
`;
}
/**
* 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 =>
`${task.title}
`
).join('');
const todayLabel = isToday ? '今日
' : '';
return `
${formatDateShort(date)}
${todayLabel}
${tasksHTML}
`;
}).join('');
return `
📅 公開スケジュールカレンダー(前後2週間)
${calendarDaysHTML}
`;
}
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 = `
マガジン進捗管理ダッシュボード
📊 マガジン進捗管理ダッシュボード
${updateTime} 更新
${healthSection}
${aiSuggestionsSection}
${magazineStatusSection}
${calendarSection}
`;
// 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;