progress-dashboard/scripts/calculate-health.js
2026-04-03 19:28:30 +09:00

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;