✨ 주요 개선사항: - 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>
1082 lines
31 KiB
JavaScript
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,
|
|
};
|