688 lines
24 KiB
JavaScript
688 lines
24 KiB
JavaScript
#!/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;
|