Files
zellyy-finance/scripts/linear-dashboard-generator.cjs
hansoo 8343b25439 feat: Stage 2 TypeScript 타입 안전성 개선 - any 타입 83개 → 62개 대폭 감소
 주요 개선사항:
- any 타입 83개에서 62개로 21개 수정 (25% 감소)
- 모든 ESLint 에러 11개 → 0개 완전 해결
- 타입 안전성 대폭 향상으로 런타임 오류 가능성 감소

🔧 수정된 파일들:
• PWADebug.tsx - 사용하지 않는 import들에 _ prefix 추가
• categoryUtils.ts - 불필요한 any 캐스트 제거
• TransactionsHeader.tsx - BudgetData 인터페이스 정의
• storageUtils.ts - generic 타입과 unknown 타입 적용
• 각종 error handler들 - Error | {message?: string} 타입 적용
• test 파일들 - 적절한 mock 인터페이스 정의
• 유틸리티 파일들 - any → unknown 또는 적절한 타입으로 교체

🏆 성과:
- 코드 품질 크게 향상 (280 → 80 문제로 71% 감소)
- TypeScript 컴파일러의 타입 체크 효과성 증대
- 개발자 경험 개선 (IDE 자동완성, 타입 추론 등)

🧹 추가 정리:
- ESLint no-console/no-alert 경고 해결
- Prettier 포맷팅 적용으로 코드 스타일 통일

🎯 다음 단계: 남은 62개 any 타입 계속 개선 예정

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 10:08:51 +09:00

1082 lines
31 KiB
JavaScript

#!/usr/bin/env node
/**
* Linear 프로젝트 대시보드 생성기
* Linear 데이터를 기반으로 자동화된 프로젝트 리포팅 대시보드 생성
*/
const fs = require("fs");
const path = require("path");
// Simple command line argument parsing
const args = process.argv.slice(2);
const options = {
apiKey: null,
period: "7d", // 7d, 30d, 90d
output: "dashboard",
format: "html", // html, json, markdown
verbose: args.includes("--verbose") || args.includes("-v"),
help: args.includes("--help") || args.includes("-h"),
};
// Extract arguments
args.forEach((arg) => {
if (arg.startsWith("--api-key=")) {
options.apiKey = arg.split("=")[1];
} else if (arg.startsWith("--period=")) {
options.period = arg.split("=")[1];
} else if (arg.startsWith("--output=")) {
options.output = arg.split("=")[1];
} else if (arg.startsWith("--format=")) {
options.format = arg.split("=")[1];
}
});
/**
* Linear API 클라이언트
*/
class LinearDashboardClient {
constructor(apiKey) {
this.apiKey = apiKey;
this.baseUrl = "https://api.linear.app/graphql";
}
async query(query, variables = {}) {
try {
const response = await fetch(this.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: this.apiKey,
},
body: JSON.stringify({ query, variables }),
});
const data = await response.json();
if (data.errors) {
throw new Error(`Linear API Error: ${JSON.stringify(data.errors)}`);
}
return data.data;
} catch (error) {
throw new Error(`Linear API call failed: ${error.message}`);
}
}
async getOrganizationData() {
const query = `
query GetOrganizationData {
organization {
id
name
urlKey
createdAt
userCount
teams {
nodes {
id
name
key
description
issueCount
members {
nodes {
id
name
email
isMe
}
}
}
}
}
}
`;
return await this.query(query);
}
async getProjectStats(period = "30d") {
const sinceDate = this.getPeriodDate(period);
const query = `
query GetProjectStats($since: DateTimeOrDuration!) {
issues(
filter: {
updatedAt: { gte: $since }
}
first: 250
) {
nodes {
id
identifier
title
description
state {
id
name
type
}
priority
estimate
labels {
nodes {
id
name
color
}
}
assignee {
id
name
email
}
team {
id
name
key
}
project {
id
name
description
}
createdAt
updatedAt
completedAt
cycle {
id
name
number
}
}
}
projects(first: 50) {
nodes {
id
name
description
state
progress
startDate
targetDate
completedAt
lead {
id
name
}
teams {
nodes {
id
name
}
}
issues {
nodes {
id
state {
name
type
}
}
}
}
}
}
`;
return await this.query(query, { since: sinceDate.toISOString() });
}
async getCycleData() {
const query = `
query GetCycleData {
cycles(
filter: {
isActive: { eq: true }
}
first: 10
) {
nodes {
id
name
number
description
startsAt
endsAt
progress
team {
id
name
}
issues {
nodes {
id
identifier
title
state {
name
type
}
estimate
assignee {
name
}
}
}
}
}
}
`;
return await this.query(query);
}
getPeriodDate(period) {
const now = new Date();
const days = parseInt(period.replace("d", ""));
const sinceDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
return sinceDate;
}
}
/**
* 대시보드 데이터 분석기
*/
class DashboardAnalyzer {
constructor(data) {
this.data = data;
}
analyzeIssueMetrics() {
const issues = this.data.projectStats.issues.nodes;
const metrics = {
total: issues.length,
byState: {},
byPriority: {},
byTeam: {},
byAssignee: {},
completed: 0,
inProgress: 0,
backlog: 0,
avgCompletionTime: 0,
estimateAccuracy: 0,
};
let completionTimes = [];
let estimateData = [];
issues.forEach((issue) => {
// State analysis
const stateName = issue.state.name;
metrics.byState[stateName] = (metrics.byState[stateName] || 0) + 1;
if (issue.state.type === "completed") {
metrics.completed++;
// Completion time calculation
if (issue.completedAt && issue.createdAt) {
const completion = new Date(issue.completedAt);
const creation = new Date(issue.createdAt);
const days = (completion - creation) / (1000 * 60 * 60 * 24);
completionTimes.push(days);
}
} else if (["started", "unstarted"].includes(issue.state.type)) {
metrics.inProgress++;
} else {
metrics.backlog++;
}
// Priority analysis
const priority = issue.priority || 0;
metrics.byPriority[priority] = (metrics.byPriority[priority] || 0) + 1;
// Team analysis
const teamName = issue.team?.name || "No Team";
metrics.byTeam[teamName] = (metrics.byTeam[teamName] || 0) + 1;
// Assignee analysis
const assigneeName = issue.assignee?.name || "Unassigned";
metrics.byAssignee[assigneeName] =
(metrics.byAssignee[assigneeName] || 0) + 1;
// Estimate accuracy
if (issue.estimate && issue.completedAt) {
estimateData.push({
estimate: issue.estimate,
actual: completionTimes[completionTimes.length - 1] || 0,
});
}
});
// Calculate averages
if (completionTimes.length > 0) {
metrics.avgCompletionTime =
completionTimes.reduce((a, b) => a + b, 0) / completionTimes.length;
}
if (estimateData.length > 0) {
const accuracyScores = estimateData.map((item) => {
const accuracy =
1 -
Math.abs(item.actual - item.estimate) /
Math.max(item.actual, item.estimate, 1);
return Math.max(0, accuracy);
});
metrics.estimateAccuracy =
accuracyScores.reduce((a, b) => a + b, 0) / accuracyScores.length;
}
return metrics;
}
analyzeProjectMetrics() {
const projects = this.data.projectStats.projects.nodes;
const metrics = {
total: projects.length,
active: 0,
completed: 0,
onTrack: 0,
delayed: 0,
byLead: {},
avgProgress: 0,
healthScore: 0,
};
let progressValues = [];
let healthScores = [];
projects.forEach((project) => {
// State analysis
if (project.state === "completed") {
metrics.completed++;
} else {
metrics.active++;
}
// Progress analysis
if (project.progress !== null && project.progress !== undefined) {
progressValues.push(project.progress);
// Health score calculation
let healthScore = project.progress;
// Check if on track with timeline
if (project.targetDate) {
const now = new Date();
const target = new Date(project.targetDate);
const start = project.startDate ? new Date(project.startDate) : now;
const totalDuration = target - start;
const elapsed = now - start;
const expectedProgress = Math.min(
1,
Math.max(0, elapsed / totalDuration)
);
if (project.progress >= expectedProgress * 0.9) {
metrics.onTrack++;
healthScore *= 1.1; // Bonus for being on track
} else {
metrics.delayed++;
healthScore *= 0.8; // Penalty for being delayed
}
}
healthScores.push(Math.min(1, healthScore));
}
// Lead analysis
const leadName = project.lead?.name || "No Lead";
metrics.byLead[leadName] = (metrics.byLead[leadName] || 0) + 1;
});
// Calculate averages
if (progressValues.length > 0) {
metrics.avgProgress =
progressValues.reduce((a, b) => a + b, 0) / progressValues.length;
}
if (healthScores.length > 0) {
metrics.healthScore =
healthScores.reduce((a, b) => a + b, 0) / healthScores.length;
}
return metrics;
}
analyzeCycleMetrics() {
const cycles = this.data.cycleData.cycles.nodes;
const metrics = {
total: cycles.length,
avgProgress: 0,
totalIssues: 0,
completedIssues: 0,
velocity: 0,
burndown: [],
};
let progressValues = [];
let velocityData = [];
cycles.forEach((cycle) => {
if (cycle.progress !== null && cycle.progress !== undefined) {
progressValues.push(cycle.progress);
}
const issues = cycle.issues.nodes;
metrics.totalIssues += issues.length;
let completedInCycle = 0;
let totalEstimate = 0;
let completedEstimate = 0;
issues.forEach((issue) => {
if (issue.state.type === "completed") {
completedInCycle++;
metrics.completedIssues++;
}
if (issue.estimate) {
totalEstimate += issue.estimate;
if (issue.state.type === "completed") {
completedEstimate += issue.estimate;
}
}
});
// Velocity calculation (story points per cycle)
if (totalEstimate > 0) {
velocityData.push(completedEstimate);
}
// Burndown data
if (cycle.startsAt && cycle.endsAt) {
const start = new Date(cycle.startsAt);
const end = new Date(cycle.endsAt);
const now = new Date();
const progress = Math.min(
1,
Math.max(0, (now - start) / (end - start))
);
const remainingWork = totalEstimate - completedEstimate;
metrics.burndown.push({
cycleName: cycle.name,
progress: progress,
remainingWork: remainingWork,
totalWork: totalEstimate,
completedWork: completedEstimate,
});
}
});
// Calculate averages
if (progressValues.length > 0) {
metrics.avgProgress =
progressValues.reduce((a, b) => a + b, 0) / progressValues.length;
}
if (velocityData.length > 0) {
metrics.velocity =
velocityData.reduce((a, b) => a + b, 0) / velocityData.length;
}
return metrics;
}
analyzeTeamMetrics() {
const organization = this.data.organizationData.organization;
const issues = this.data.projectStats.issues.nodes;
const metrics = {
totalTeams: organization.teams.nodes.length,
totalMembers: organization.userCount,
teamsData: {},
};
// Initialize team data
organization.teams.nodes.forEach((team) => {
metrics.teamsData[team.name] = {
id: team.id,
key: team.key,
memberCount: team.members.nodes.length,
totalIssues: 0,
completedIssues: 0,
inProgressIssues: 0,
velocity: 0,
avgCompletionTime: 0,
members: team.members.nodes.map((member) => ({
name: member.name,
email: member.email,
isMe: member.isMe,
assignedIssues: 0,
completedIssues: 0,
})),
};
});
// Analyze issues by team
issues.forEach((issue) => {
const teamName = issue.team?.name;
if (teamName && metrics.teamsData[teamName]) {
const teamData = metrics.teamsData[teamName];
teamData.totalIssues++;
if (issue.state.type === "completed") {
teamData.completedIssues++;
} else if (["started", "unstarted"].includes(issue.state.type)) {
teamData.inProgressIssues++;
}
// Member-specific metrics
const assigneeName = issue.assignee?.name;
if (assigneeName) {
const member = teamData.members.find((m) => m.name === assigneeName);
if (member) {
member.assignedIssues++;
if (issue.state.type === "completed") {
member.completedIssues++;
}
}
}
}
});
// Calculate team velocities and completion rates
Object.values(metrics.teamsData).forEach((team) => {
if (team.totalIssues > 0) {
team.velocity = team.completedIssues / team.totalIssues;
}
});
return metrics;
}
generateSummary() {
const issueMetrics = this.analyzeIssueMetrics();
const projectMetrics = this.analyzeProjectMetrics();
const cycleMetrics = this.analyzeCycleMetrics();
const teamMetrics = this.analyzeTeamMetrics();
return {
generated: new Date().toISOString(),
period: options.period,
organization: this.data.organizationData.organization.name,
summary: {
totalIssues: issueMetrics.total,
completedIssues: issueMetrics.completed,
completionRate:
issueMetrics.total > 0
? issueMetrics.completed / issueMetrics.total
: 0,
avgCompletionTime: issueMetrics.avgCompletionTime,
totalProjects: projectMetrics.total,
projectHealthScore: projectMetrics.healthScore,
totalTeams: teamMetrics.totalTeams,
totalMembers: teamMetrics.totalMembers,
velocity: cycleMetrics.velocity,
},
issues: issueMetrics,
projects: projectMetrics,
cycles: cycleMetrics,
teams: teamMetrics,
};
}
}
/**
* HTML 대시보드 생성기
*/
class HtmlDashboardGenerator {
constructor(summary) {
this.summary = summary;
}
generate() {
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Linear Project Dashboard - ${this.summary.organization}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.header { background: white; padding: 30px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header h1 { color: #2563eb; margin-bottom: 10px; }
.header .meta { color: #666; font-size: 14px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-bottom: 30px; }
.card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.card h3 { color: #1f2937; margin-bottom: 15px; font-size: 18px; }
.metric { display: flex; justify-content: space-between; margin-bottom: 10px; }
.metric-label { color: #6b7280; }
.metric-value { font-weight: 600; }
.chart-container { height: 300px; margin-top: 20px; }
.progress-bar { width: 100%; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; margin: 10px 0; }
.progress-fill { height: 100%; background: #10b981; transition: width 0.3s ease; }
.status-good { color: #10b981; }
.status-warning { color: #f59e0b; }
.status-danger { color: #ef4444; }
.team-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px; }
.team-card { background: #f8fafc; padding: 15px; border-radius: 6px; border-left: 4px solid #3b82f6; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 Linear Project Dashboard</h1>
<div class="meta">
Organization: ${this.summary.organization} |
Period: ${this.summary.period} |
Generated: ${new Date(this.summary.generated).toLocaleString("ko-KR")}
</div>
</div>
<div class="grid">
${this.generateSummaryCard()}
${this.generateIssueMetricsCard()}
${this.generateProjectMetricsCard()}
${this.generateCycleMetricsCard()}
</div>
<div class="card">
<h3>📈 Issue Status Distribution</h3>
<div class="chart-container">
<canvas id="issueStatusChart"></canvas>
</div>
</div>
<div class="card">
<h3>👥 Team Performance</h3>
<div class="team-grid">
${this.generateTeamCards()}
</div>
</div>
<div class="footer">
Generated by Linear Dashboard Generator |
Data from Linear API |
Report Period: ${this.summary.period}
</div>
</div>
<script>
${this.generateChartScripts()}
</script>
</body>
</html>`;
}
generateSummaryCard() {
const s = this.summary.summary;
return `
<div class="card">
<h3>📋 Project Summary</h3>
<div class="metric">
<span class="metric-label">Total Issues</span>
<span class="metric-value">${s.totalIssues}</span>
</div>
<div class="metric">
<span class="metric-label">Completed Issues</span>
<span class="metric-value">${s.completedIssues}</span>
</div>
<div class="metric">
<span class="metric-label">Completion Rate</span>
<span class="metric-value ${s.completionRate > 0.8 ? "status-good" : s.completionRate > 0.6 ? "status-warning" : "status-danger"}">${(s.completionRate * 100).toFixed(1)}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${s.completionRate * 100}%"></div>
</div>
<div class="metric">
<span class="metric-label">Avg Completion Time</span>
<span class="metric-value">${s.avgCompletionTime.toFixed(1)} days</span>
</div>
<div class="metric">
<span class="metric-label">Team Velocity</span>
<span class="metric-value">${s.velocity.toFixed(1)} pts/cycle</span>
</div>
</div>
`;
}
generateIssueMetricsCard() {
const issues = this.summary.issues;
return `
<div class="card">
<h3>🎯 Issue Metrics</h3>
<div class="metric">
<span class="metric-label">Total Issues</span>
<span class="metric-value">${issues.total}</span>
</div>
<div class="metric">
<span class="metric-label">Completed</span>
<span class="metric-value status-good">${issues.completed}</span>
</div>
<div class="metric">
<span class="metric-label">In Progress</span>
<span class="metric-value status-warning">${issues.inProgress}</span>
</div>
<div class="metric">
<span class="metric-label">Backlog</span>
<span class="metric-value">${issues.backlog}</span>
</div>
<div class="metric">
<span class="metric-label">Estimate Accuracy</span>
<span class="metric-value">${(issues.estimateAccuracy * 100).toFixed(1)}%</span>
</div>
</div>
`;
}
generateProjectMetricsCard() {
const projects = this.summary.projects;
return `
<div class="card">
<h3>🚀 Project Metrics</h3>
<div class="metric">
<span class="metric-label">Total Projects</span>
<span class="metric-value">${projects.total}</span>
</div>
<div class="metric">
<span class="metric-label">Active Projects</span>
<span class="metric-value">${projects.active}</span>
</div>
<div class="metric">
<span class="metric-label">Completed Projects</span>
<span class="metric-value status-good">${projects.completed}</span>
</div>
<div class="metric">
<span class="metric-label">On Track</span>
<span class="metric-value status-good">${projects.onTrack}</span>
</div>
<div class="metric">
<span class="metric-label">Delayed</span>
<span class="metric-value status-danger">${projects.delayed}</span>
</div>
<div class="metric">
<span class="metric-label">Health Score</span>
<span class="metric-value ${projects.healthScore > 0.8 ? "status-good" : projects.healthScore > 0.6 ? "status-warning" : "status-danger"}">${(projects.healthScore * 100).toFixed(1)}%</span>
</div>
</div>
`;
}
generateCycleMetricsCard() {
const cycles = this.summary.cycles;
return `
<div class="card">
<h3>🔄 Cycle Metrics</h3>
<div class="metric">
<span class="metric-label">Active Cycles</span>
<span class="metric-value">${cycles.total}</span>
</div>
<div class="metric">
<span class="metric-label">Total Issues</span>
<span class="metric-value">${cycles.totalIssues}</span>
</div>
<div class="metric">
<span class="metric-label">Completed Issues</span>
<span class="metric-value status-good">${cycles.completedIssues}</span>
</div>
<div class="metric">
<span class="metric-label">Average Progress</span>
<span class="metric-value">${(cycles.avgProgress * 100).toFixed(1)}%</span>
</div>
<div class="metric">
<span class="metric-label">Velocity</span>
<span class="metric-value">${cycles.velocity.toFixed(1)} pts/cycle</span>
</div>
</div>
`;
}
generateTeamCards() {
return Object.entries(this.summary.teams.teamsData)
.map(
([teamName, team]) => `
<div class="team-card">
<h4>${teamName} (${team.key})</h4>
<div style="font-size: 12px; color: #666; margin: 5px 0;">
${team.memberCount} members
</div>
<div class="metric">
<span class="metric-label">Issues</span>
<span class="metric-value">${team.totalIssues}</span>
</div>
<div class="metric">
<span class="metric-label">Completed</span>
<span class="metric-value status-good">${team.completedIssues}</span>
</div>
<div class="metric">
<span class="metric-label">Velocity</span>
<span class="metric-value">${(team.velocity * 100).toFixed(1)}%</span>
</div>
</div>
`
)
.join("");
}
generateChartScripts() {
const issueStates = this.summary.issues.byState;
const stateLabels = Object.keys(issueStates);
const stateData = Object.values(issueStates);
return `
// Issue Status Chart
const ctx = document.getElementById('issueStatusChart').getContext('2d');
new Chart(ctx, {
type: 'doughnut',
data: {
labels: ${JSON.stringify(stateLabels)},
datasets: [{
data: ${JSON.stringify(stateData)},
backgroundColor: [
'#10b981', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
`;
}
}
/**
* 사용법 출력
*/
function printUsage() {
console.log(`
📊 Linear 프로젝트 대시보드 생성기
사용법:
node scripts/linear-dashboard-generator.cjs [옵션]
옵션:
--api-key=KEY Linear API 키 지정
--period=PERIOD 데이터 수집 기간 (7d, 30d, 90d) [기본: 7d]
--output=NAME 출력 파일명 [기본: dashboard]
--format=FORMAT 출력 형식 (html, json, markdown) [기본: html]
--verbose, -v 상세 출력
--help, -h 이 도움말 출력
예시:
# HTML 대시보드 생성
node scripts/linear-dashboard-generator.cjs --api-key=lin_api_xxx
# 30일 기간의 JSON 보고서 생성
node scripts/linear-dashboard-generator.cjs --period=30d --format=json
# 커스텀 파일명으로 대시보드 생성
node scripts/linear-dashboard-generator.cjs --output=weekly-report
환경 변수:
LINEAR_API_KEY Linear API 키
출력 파일:
- HTML: dashboard.html (브라우저에서 열기)
- JSON: dashboard.json (데이터 분석용)
- Markdown: dashboard.md (문서화용)
`);
}
/**
* 메인 함수
*/
async function main() {
if (options.help) {
printUsage();
process.exit(0);
}
console.log("📊 Linear 프로젝트 대시보드 생성 중...\n");
// API 키 확인
const apiKey = options.apiKey || process.env.LINEAR_API_KEY;
if (!apiKey) {
console.error(
"❌ Linear API 키가 필요합니다. --api-key 옵션을 사용하거나 LINEAR_API_KEY 환경 변수를 설정하세요."
);
process.exit(1);
}
try {
// Linear 클라이언트 초기화
const client = new LinearDashboardClient(apiKey);
console.log("🔗 Linear API에서 데이터 수집 중...");
// 데이터 수집
const [organizationData, projectStats, cycleData] = await Promise.all([
client.getOrganizationData(),
client.getProjectStats(options.period),
client.getCycleData(),
]);
const data = {
organizationData,
projectStats,
cycleData,
};
if (options.verbose) {
console.log("📋 수집된 데이터:");
console.log(` - 조직: ${organizationData.organization.name}`);
console.log(
` - 팀: ${organizationData.organization.teams.nodes.length}`
);
console.log(` - 이슈: ${projectStats.issues.nodes.length}`);
console.log(` - 프로젝트: ${projectStats.projects.nodes.length}`);
console.log(` - 사이클: ${cycleData.cycles.nodes.length}`);
}
// 데이터 분석
console.log("📊 데이터 분석 중...");
const analyzer = new DashboardAnalyzer(data);
const summary = analyzer.generateSummary();
// 출력 디렉토리 생성
const outputDir = "reports";
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// 대시보드 생성
console.log(`📄 ${options.format.toUpperCase()} 대시보드 생성 중...`);
let outputPath;
let content;
switch (options.format) {
case "html":
const htmlGenerator = new HtmlDashboardGenerator(summary);
content = htmlGenerator.generate();
outputPath = path.join(outputDir, `${options.output}.html`);
break;
case "json":
content = JSON.stringify(summary, null, 2);
outputPath = path.join(outputDir, `${options.output}.json`);
break;
case "markdown":
content = generateMarkdownReport(summary);
outputPath = path.join(outputDir, `${options.output}.md`);
break;
default:
console.error(`❌ 지원하지 않는 형식: ${options.format}`);
process.exit(1);
}
// 파일 저장
fs.writeFileSync(outputPath, content);
console.log("✅ 대시보드 생성 완료!");
console.log(`📁 파일 위치: ${outputPath}`);
if (options.format === "html") {
console.log("🌐 브라우저에서 열어서 확인하세요.");
}
// 요약 정보 출력
console.log("\n📈 프로젝트 요약:");
console.log(` 총 이슈: ${summary.summary.totalIssues}`);
console.log(
` 완료율: ${(summary.summary.completionRate * 100).toFixed(1)}%`
);
console.log(
` 프로젝트 건강도: ${(summary.summary.projectHealthScore * 100).toFixed(1)}%`
);
console.log(` 팀 속도: ${summary.summary.velocity.toFixed(1)} pts/cycle`);
} catch (error) {
console.error("❌ 대시보드 생성 실패:", error.message);
process.exit(1);
}
}
/**
* 마크다운 보고서 생성
*/
function generateMarkdownReport(summary) {
return `# Linear Project Dashboard
**Organization**: ${summary.organization}
**Period**: ${summary.period}
**Generated**: ${new Date(summary.generated).toLocaleString("ko-KR")}
## 📋 Executive Summary
- **Total Issues**: ${summary.summary.totalIssues}
- **Completion Rate**: ${(summary.summary.completionRate * 100).toFixed(1)}%
- **Average Completion Time**: ${summary.summary.avgCompletionTime.toFixed(1)} days
- **Project Health Score**: ${(summary.summary.projectHealthScore * 100).toFixed(1)}%
- **Team Velocity**: ${summary.summary.velocity.toFixed(1)} points/cycle
## 🎯 Issue Metrics
| Metric | Value |
|--------|-------|
| Total Issues | ${summary.issues.total} |
| Completed | ${summary.issues.completed} |
| In Progress | ${summary.issues.inProgress} |
| Backlog | ${summary.issues.backlog} |
| Estimate Accuracy | ${(summary.issues.estimateAccuracy * 100).toFixed(1)}% |
## 🚀 Project Status
| Metric | Value |
|--------|-------|
| Total Projects | ${summary.projects.total} |
| Active | ${summary.projects.active} |
| Completed | ${summary.projects.completed} |
| On Track | ${summary.projects.onTrack} |
| Delayed | ${summary.projects.delayed} |
## 👥 Team Performance
${Object.entries(summary.teams.teamsData)
.map(
([teamName, team]) => `
### ${teamName} (${team.key})
- **Members**: ${team.memberCount}
- **Total Issues**: ${team.totalIssues}
- **Completed**: ${team.completedIssues}
- **Velocity**: ${(team.velocity * 100).toFixed(1)}%
`
)
.join("")}
---
*Generated by Linear Dashboard Generator*`;
}
// 실행
if (require.main === module) {
main();
}
module.exports = {
LinearDashboardClient,
DashboardAnalyzer,
HtmlDashboardGenerator,
};