Add economic calendar feature with n8n integration
All checks were successful
Deploy to Server / deploy (push) Successful in 36s

- Add economic calendar tab with monthly view
- Display today's events in header
- Add weekly summary section
- Integrate with Forex Factory via n8n webhook
- Add Header Auth API authentication
- Add KST timezone conversion
- Add country filter (US, JP, CN)
- Add importance-based event styling
- Add more events modal for days with many events
- Update calendar grid to show up to 4 events per day
- Add n8n workflow configuration files
This commit is contained in:
kihong.kim
2025-12-31 15:24:30 +09:00
parent 4c351196b7
commit 022fddec9c
7 changed files with 1850 additions and 13 deletions

621
app.js
View File

@@ -1057,7 +1057,628 @@ class TradingView {
}
}
// Economic Calendar Class
class EconomicCalendar {
constructor() {
this.events = [];
this.filteredEvents = [];
this.selectedCountry = 'all';
this.selectedPeriod = 'week';
this.currentMonth = new Date();
// 데이터 소스 설정:
// - 로컬 JSON 파일: 'data/economic-calendar.json'
// - n8n webhook: 'https://n8n.binibini.dedyn.io/webhook/economic-calendar'
this.dataUrl = 'https://n8n.binibini.dedyn.io/webhook/economic-calendar';
// API 인증 키 (n8n Header Auth와 동일한 값 사용)
// 키 생성: openssl rand -hex 32
this.apiKey = 'd46c4eb3b8ce1864c4994c45db11f80f1732fecd3caebd56518f5f6ce2be205e';
this.init();
}
async init() {
this.setupEventListeners();
await this.loadCalendarData();
this.renderHeaderEvents();
this.renderWeeklySummary();
this.renderCalendarList();
}
setupEventListeners() {
// Tab navigation
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const tab = e.target.dataset.tab;
this.switchTab(tab);
});
});
// Country filter buttons
document.querySelectorAll('.country-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.country-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
this.selectedCountry = e.target.dataset.country;
this.renderCalendarList();
});
});
// Period filter
document.getElementById('calendar-period')?.addEventListener('change', (e) => {
this.selectedPeriod = e.target.value;
this.renderCalendarList();
});
// "More" button to switch to calendar tab
document.getElementById('show-calendar-btn')?.addEventListener('click', () => {
this.switchTab('calendar');
});
}
switchTab(tab) {
// Update tab buttons
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
// Update tab content
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.toggle('active', content.id === `${tab}-tab`);
});
// Show/hide timeframe selector
const timeframeSelect = document.getElementById('timeframe');
if (timeframeSelect) {
timeframeSelect.style.display = tab === 'chart' ? 'block' : 'none';
}
}
async loadCalendarData() {
try {
const headers = {};
// API 키가 설정된 경우 인증 헤더 추가
if (this.apiKey && this.apiKey !== 'YOUR_API_KEY_HERE') {
headers['X-API-Key'] = this.apiKey;
}
const response = await fetch(this.dataUrl, { headers });
if (response.ok) {
const data = await response.json();
// n8n webhook returns {data: [...]}, local JSON returns [...]
this.events = Array.isArray(data) ? data : (data.data || []);
} else if (response.status === 401 || response.status === 403) {
console.warn('API 인증 실패: API 키를 확인하세요');
this.events = this.getSampleData();
} else {
// Use sample data if file doesn't exist
this.events = this.getSampleData();
}
} catch (error) {
console.log('Using sample calendar data');
this.events = this.getSampleData();
}
}
getSampleData() {
const today = new Date();
// 로컬 시간 기준 YYYY-MM-DD 형식 반환 (UTC 변환으로 인한 날짜 밀림 방지)
const getDateStr = (daysOffset) => {
const d = new Date(today);
d.setDate(d.getDate() + daysOffset);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
return [
{
date: getDateStr(0),
time: '09:00',
country: 'CN',
name: '제조업 PMI',
importance: 3,
forecast: '50.2',
previous: '50.3',
actual: null
},
{
date: getDateStr(0),
time: '23:00',
country: 'US',
name: 'CB 소비자신뢰지수',
importance: 3,
forecast: '112.0',
previous: '111.7',
actual: null
},
{
date: getDateStr(1),
time: '08:30',
country: 'JP',
name: '실업률',
importance: 2,
forecast: '2.5%',
previous: '2.5%',
actual: null
},
{
date: getDateStr(1),
time: '10:00',
country: 'KR',
name: '산업생산 (MoM)',
importance: 2,
forecast: '0.3%',
previous: '-0.3%',
actual: null
},
{
date: getDateStr(2),
time: '22:00',
country: 'US',
name: 'ISM 제조업 PMI',
importance: 3,
forecast: '48.0',
previous: '46.7',
actual: null
},
{
date: getDateStr(3),
time: '04:00',
country: 'US',
name: 'FOMC 회의록',
importance: 3,
forecast: '-',
previous: '-',
actual: null
},
{
date: getDateStr(4),
time: '09:30',
country: 'CN',
name: 'Caixin 서비스업 PMI',
importance: 2,
forecast: '51.5',
previous: '51.5',
actual: null
},
{
date: getDateStr(4),
time: '22:30',
country: 'US',
name: '비농업 고용지수 (NFP)',
importance: 3,
forecast: '180K',
previous: '227K',
actual: null
},
{
date: getDateStr(5),
time: '08:00',
country: 'KR',
name: 'CPI (YoY)',
importance: 3,
forecast: '1.8%',
previous: '1.5%',
actual: null
},
{
date: getDateStr(6),
time: '10:30',
country: 'JP',
name: '경상수지',
importance: 2,
forecast: '2.5T',
previous: '2.3T',
actual: null
}
];
}
getCountryFlag(countryCode) {
const flags = {
'US': '🇺🇸',
'KR': '🇰🇷',
'CN': '🇨🇳',
'JP': '🇯🇵'
};
return flags[countryCode] || '🌐';
}
getFilteredEvents() {
let filtered = [...this.events];
// Filter by country
if (this.selectedCountry !== 'all') {
filtered = filtered.filter(e => e.country === this.selectedCountry);
}
// Filter by period
const today = new Date();
today.setHours(0, 0, 0, 0);
if (this.selectedPeriod === 'week') {
const weekEnd = new Date(today);
weekEnd.setDate(weekEnd.getDate() + 7);
filtered = filtered.filter(e => {
const eventDate = new Date(e.date);
return eventDate >= today && eventDate < weekEnd;
});
} else if (this.selectedPeriod === 'month') {
const monthEnd = new Date(today);
monthEnd.setMonth(monthEnd.getMonth() + 1);
filtered = filtered.filter(e => {
const eventDate = new Date(e.date);
return eventDate >= today && eventDate < monthEnd;
});
}
// Sort by date and time
filtered.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date);
if (dateCompare !== 0) return dateCompare;
return a.time.localeCompare(b.time);
});
return filtered;
}
renderHeaderEvents() {
const container = document.getElementById('header-events');
if (!container) return;
// Get today's date string (로컬 시간 기준)
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Filter today's events (importance === 3 only)
const todayEvents = this.events
.filter(e => e.date === todayStr && e.importance === 3)
.sort((a, b) => a.time.localeCompare(b.time))
.slice(0, 4); // Maximum 4 events in header
if (todayEvents.length === 0) {
container.innerHTML = '<span class="header-events-empty">오늘 주요 일정 없음</span>';
return;
}
container.innerHTML = todayEvents.map(event => {
const stars = '★'.repeat(event.importance);
return `
<div class="header-event-item importance-${event.importance}" title="${event.name} - ${event.forecast || '-'}">
<span class="header-event-time">${event.time}</span>
<span class="header-event-country">${this.getCountryFlag(event.country)}</span>
<span class="header-event-name">${event.name}</span>
<span class="header-event-stars">${stars}</span>
</div>
`;
}).join('');
}
renderWeeklySummary() {
const container = document.getElementById('weekly-events');
if (!container) return;
const weekEvents = this.events
.filter(e => {
const eventDate = new Date(e.date);
const today = new Date();
today.setHours(0, 0, 0, 0);
const weekEnd = new Date(today);
weekEnd.setDate(weekEnd.getDate() + 7);
return eventDate >= today && eventDate < weekEnd && e.importance >= 2;
})
.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date);
if (dateCompare !== 0) return dateCompare;
return a.time.localeCompare(b.time);
})
.slice(0, 8);
if (weekEvents.length === 0) {
container.innerHTML = '<span class="loading-text">이번주 주요 일정이 없습니다</span>';
return;
}
container.innerHTML = weekEvents.map(event => {
const eventDate = new Date(event.date);
const dateStr = `${eventDate.getMonth() + 1}/${eventDate.getDate()}`;
const stars = '★'.repeat(event.importance);
return `
<div class="weekly-event-item">
<span class="weekly-event-date">${dateStr}</span>
<span class="weekly-event-country">${this.getCountryFlag(event.country)}</span>
<span class="weekly-event-name">${event.name}</span>
<span class="weekly-event-importance">${stars}</span>
</div>
`;
}).join('');
}
renderCalendarList() {
const container = document.getElementById('calendar-list');
if (!container) return;
// 월간 선택 시 캘린더 그리드로 표시
if (this.selectedPeriod === 'month') {
this.renderMonthlyCalendar(container);
return;
}
// 주간 선택 시 리스트로 표시
const events = this.getFilteredEvents();
if (events.length === 0) {
container.innerHTML = '<div class="loading-text">해당 기간에 일정이 없습니다</div>';
return;
}
// Group events by date
const grouped = events.reduce((acc, event) => {
if (!acc[event.date]) {
acc[event.date] = [];
}
acc[event.date].push(event);
return acc;
}, {});
const html = Object.entries(grouped).map(([date, dayEvents]) => {
const eventDate = new Date(date);
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const dateStr = `${eventDate.getMonth() + 1}${eventDate.getDate()}일 (${dayNames[eventDate.getDay()]})`;
const eventsHtml = dayEvents.map(event => {
const stars = [1, 2, 3].map(i =>
`<span class="importance-star ${i <= event.importance ? 'active' : ''}">★</span>`
).join('');
return `
<div class="calendar-event">
<span class="event-time">${event.time}</span>
<span class="event-country">${this.getCountryFlag(event.country)}</span>
<div class="event-info">
<div class="event-name">${event.name}</div>
</div>
<div class="event-importance">${stars}</div>
<div class="event-values">
<div class="event-value">
<div class="event-value-label">예상</div>
<div class="event-value-number">${event.forecast || '-'}</div>
</div>
<div class="event-value">
<div class="event-value-label">이전</div>
<div class="event-value-number">${event.previous || '-'}</div>
</div>
<div class="event-value">
<div class="event-value-label">실제</div>
<div class="event-value-number ${event.actual ? (parseFloat(event.actual) > parseFloat(event.forecast) ? 'positive' : 'negative') : ''}">${event.actual || '-'}</div>
</div>
</div>
</div>
`;
}).join('');
return `
<div class="calendar-date-group">
<div class="calendar-date-header">${dateStr}</div>
${eventsHtml}
</div>
`;
}).join('');
container.innerHTML = html;
}
renderMonthlyCalendar(container) {
const year = this.currentMonth.getFullYear();
const month = this.currentMonth.getMonth();
// 해당 월의 첫째 날과 마지막 날
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// 캘린더 시작일 (해당 월 첫째 날이 속한 주의 일요일)
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay());
// 캘린더 종료일 (해당 월 마지막 날이 속한 주의 토요일)
const endDate = new Date(lastDay);
endDate.setDate(endDate.getDate() + (6 - lastDay.getDay()));
// 이벤트를 날짜별로 그룹화
const eventsByDate = this.getMonthEvents(year, month);
// 오늘 날짜
const today = new Date();
today.setHours(0, 0, 0, 0);
// 월 제목
const monthTitle = `${year}${month + 1}`;
// 요일 헤더
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
// 날짜 셀 생성
let daysHtml = '';
const currentDate = new Date(startDate);
// 로컬 시간 기준 YYYY-MM-DD 형식 반환
const getLocalDateStr = (date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
};
while (currentDate <= endDate) {
const dateStr = getLocalDateStr(currentDate);
const isOtherMonth = currentDate.getMonth() !== month;
const isToday = currentDate.getTime() === today.getTime();
const dayEvents = eventsByDate[dateStr] || [];
// 국가 필터 적용
const filteredDayEvents = this.selectedCountry === 'all'
? dayEvents
: dayEvents.filter(e => e.country === this.selectedCountry);
// 최대 4개까지만 표시
const visibleEvents = filteredDayEvents.slice(0, 4);
const moreCount = filteredDayEvents.length - 4;
const eventsHtml = visibleEvents.map(event => {
const stars = '★'.repeat(event.importance);
return `
<div class="day-event importance-${event.importance}" title="${event.time} ${event.name}">
<span class="day-event-time">${event.time}</span>
<span class="day-event-country">${this.getCountryFlag(event.country)}</span>
<span class="day-event-name">${event.name}</span>
<span class="day-event-stars">${stars}</span>
</div>
`;
}).join('');
const moreHtml = moreCount > 0 ? `<div class="day-event-more" data-date="${dateStr}">+${moreCount}개 더보기</div>` : '';
daysHtml += `
<div class="calendar-day ${isOtherMonth ? 'other-month' : ''} ${isToday ? 'today' : ''}">
<div class="day-number">${currentDate.getDate()}</div>
<div class="day-events">
${eventsHtml}
${moreHtml}
</div>
</div>
`;
currentDate.setDate(currentDate.getDate() + 1);
}
container.innerHTML = `
<div class="calendar-grid">
<div class="calendar-grid-header">
<div class="calendar-nav">
<button class="calendar-nav-btn" id="prev-month">&lt; 이전</button>
</div>
<div class="calendar-month-title">${monthTitle}</div>
<div class="calendar-nav">
<button class="calendar-nav-btn" id="next-month">다음 &gt;</button>
</div>
</div>
<div class="calendar-weekdays">
${weekdays.map(day => `<div class="calendar-weekday">${day}</div>`).join('')}
</div>
<div class="calendar-days">
${daysHtml}
</div>
</div>
`;
// 네비게이션 버튼 이벤트
document.getElementById('prev-month')?.addEventListener('click', () => {
this.currentMonth.setMonth(this.currentMonth.getMonth() - 1);
this.renderCalendarList();
});
document.getElementById('next-month')?.addEventListener('click', () => {
this.currentMonth.setMonth(this.currentMonth.getMonth() + 1);
this.renderCalendarList();
});
// 더보기 버튼 클릭 이벤트
document.querySelectorAll('.day-event-more').forEach(btn => {
btn.addEventListener('click', (e) => {
const dateStr = e.target.dataset.date;
this.showDayEventsModal(dateStr, eventsByDate[dateStr] || []);
});
});
}
showDayEventsModal(dateStr, events) {
// 국가 필터 적용
const filteredEvents = this.selectedCountry === 'all'
? events
: events.filter(e => e.country === this.selectedCountry);
const date = new Date(dateStr);
const dateTitle = `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
const eventsHtml = filteredEvents.map(event => {
const stars = '★'.repeat(event.importance);
return `
<div class="modal-event importance-${event.importance}">
<span class="modal-event-time">${event.time}</span>
<span class="modal-event-country">${this.getCountryFlag(event.country)}</span>
<span class="modal-event-name">${event.name}</span>
<span class="modal-event-stars">${stars}</span>
</div>
`;
}).join('');
// 모달 생성
const modal = document.createElement('div');
modal.className = 'events-modal-overlay';
modal.innerHTML = `
<div class="events-modal">
<div class="events-modal-header">
<h3>${dateTitle} 일정</h3>
<button class="events-modal-close">&times;</button>
</div>
<div class="events-modal-body">
${eventsHtml}
</div>
</div>
`;
document.body.appendChild(modal);
// 닫기 이벤트
modal.querySelector('.events-modal-close').addEventListener('click', () => {
modal.remove();
});
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
}
getMonthEvents(year, month) {
const grouped = {};
this.events.forEach(event => {
const eventDate = new Date(event.date);
// 해당 월 전후 1주일까지 포함 (캘린더에 표시되는 범위)
const eventYear = eventDate.getFullYear();
const eventMonth = eventDate.getMonth();
// 해당 월 또는 인접 월의 이벤트만 포함
if ((eventYear === year && eventMonth === month) ||
(eventYear === year && eventMonth === month - 1) ||
(eventYear === year && eventMonth === month + 1) ||
(eventYear === year - 1 && month === 0 && eventMonth === 11) ||
(eventYear === year + 1 && month === 11 && eventMonth === 0)) {
if (!grouped[event.date]) {
grouped[event.date] = [];
}
grouped[event.date].push(event);
}
});
// 각 날짜의 이벤트를 시간순으로 정렬
Object.keys(grouped).forEach(date => {
grouped[date].sort((a, b) => a.time.localeCompare(b.time));
});
return grouped;
}
}
// Initialize the application
document.addEventListener('DOMContentLoaded', () => {
window.tradingView = new TradingView();
window.economicCalendar = new EconomicCalendar();
});