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

View File

@@ -4,6 +4,7 @@
## 주요 기능
### 차트 기능
- **실시간 차트**: 바이낸스 WebSocket을 통한 실시간 캔들스틱 차트
- **다양한 타임프레임**: 1분, 5분, 15분, 1시간, 4시간, 1일, 1주, 1월
- **기술적 지표**:
@@ -15,11 +16,20 @@
- 골든크로스/데드크로스 표시
- **실시간 분석 패널**: 각 지표별 매수/매도 신호 및 한글 분석 요약
### 경제 일정 캘린더
- **실시간 경제 일정**: 주요 경제 이벤트 표시 (미국, 일본, 중국)
- **헤더 오늘 일정**: 오늘의 주요 경제 일정을 헤더에 표시
- **월간 캘린더**: 월별 경제 일정 캘린더 뷰
- **국가별 필터**: 국가별 필터링 기능
- **중요도 표시**: 이벤트 중요도에 따른 시각적 구분 (★★★)
- **더보기 모달**: 일정이 많은 날짜의 상세 보기
## 기술 스택
- Vanilla JavaScript (프레임워크 없음)
- [Lightweight Charts](https://github.com/tradingview/lightweight-charts) by TradingView
- Binance WebSocket API
- n8n (경제 일정 데이터 수집)
- Docker + Nginx
## 로컬 실행
@@ -64,18 +74,46 @@ trading.yourdomain.com {
자세한 Authentik 설정 방법은 `Caddyfile.example` 파일 내 주석을 참고하세요.
## 경제 일정 설정
경제 일정 데이터는 n8n 워크플로우를 통해 [Forex Factory](https://www.forexfactory.com/calendar)에서 수집됩니다.
### 데이터 소스
- **Forex Factory JSON**: `https://nfs.faireconomy.media/ff_calendar_thisweek.json`
- **제공 범위**: 이번 주 경제 일정
- **필터링**: US, JP, CN 국가의 Medium/High 중요도 이벤트만
### n8n 워크플로우 설정
1. n8n에서 `n8n/economic-calendar-combined.json` 워크플로우 import
2. Header Auth Credential 설정 (API 키)
3. 워크플로우 활성화
자세한 설정 방법은 [n8n/README.md](n8n/README.md)를 참고하세요.
### API 인증
경제 일정 API는 Header Auth로 보호됩니다:
- 헤더: `X-API-Key`
- `app.js``apiKey` 값과 n8n Credential의 Header Value가 일치해야 합니다
## 프로젝트 구조
```
bini-trading-view/
├── index.html # 메인 HTML
├── styles.css # 스타일시트
├── app.js # 메인 애플리케이션 로직
├── indicators.js # 기술적 지표 계산 함수
├── Dockerfile # Docker 이미지 정의
├── docker-compose.yml # Docker Compose 설정
├── nginx.conf # Nginx 설정
── Caddyfile.example # Caddy + Authentik 설정 예시
├── index.html # 메인 HTML
├── styles.css # 스타일시트
├── app.js # 메인 애플리케이션 로직
├── indicators.js # 기술적 지표 계산 함수
├── Dockerfile # Docker 이미지 정의
├── docker-compose.yml # Docker Compose 설정
├── nginx.conf # Nginx 설정
── Caddyfile.example # Caddy + Authentik 설정 예시
├── data/ # 로컬 데이터 파일
│ └── economic-calendar.json
└── n8n/ # n8n 워크플로우 파일
├── README.md # n8n 설정 가이드
└── economic-calendar-combined.json
```
## 스크린샷

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();
});

132
data/economic-calendar.json Normal file
View File

@@ -0,0 +1,132 @@
[
{
"date": "2025-12-31",
"time": "09:00",
"country": "CN",
"name": "제조업 PMI",
"importance": 3,
"forecast": "50.2",
"previous": "50.3",
"actual": null
},
{
"date": "2025-12-31",
"time": "23:00",
"country": "US",
"name": "CB 소비자신뢰지수",
"importance": 3,
"forecast": "112.0",
"previous": "111.7",
"actual": null
},
{
"date": "2026-01-01",
"time": "08:30",
"country": "JP",
"name": "실업률",
"importance": 2,
"forecast": "2.5%",
"previous": "2.5%",
"actual": null
},
{
"date": "2026-01-01",
"time": "10:00",
"country": "KR",
"name": "산업생산 (MoM)",
"importance": 2,
"forecast": "0.3%",
"previous": "-0.3%",
"actual": null
},
{
"date": "2026-01-02",
"time": "22:00",
"country": "US",
"name": "ISM 제조업 PMI",
"importance": 3,
"forecast": "48.0",
"previous": "46.7",
"actual": null
},
{
"date": "2026-01-03",
"time": "04:00",
"country": "US",
"name": "FOMC 회의록",
"importance": 3,
"forecast": "-",
"previous": "-",
"actual": null
},
{
"date": "2026-01-03",
"time": "09:30",
"country": "CN",
"name": "Caixin 서비스업 PMI",
"importance": 2,
"forecast": "51.5",
"previous": "51.5",
"actual": null
},
{
"date": "2026-01-04",
"time": "22:30",
"country": "US",
"name": "비농업 고용지수 (NFP)",
"importance": 3,
"forecast": "180K",
"previous": "227K",
"actual": null
},
{
"date": "2026-01-05",
"time": "08:00",
"country": "KR",
"name": "CPI (YoY)",
"importance": 3,
"forecast": "1.8%",
"previous": "1.5%",
"actual": null
},
{
"date": "2026-01-06",
"time": "10:30",
"country": "JP",
"name": "경상수지",
"importance": 2,
"forecast": "2.5T",
"previous": "2.3T",
"actual": null
},
{
"date": "2026-01-10",
"time": "09:00",
"country": "KR",
"name": "한국은행 기준금리",
"importance": 3,
"forecast": "3.00%",
"previous": "3.00%",
"actual": null
},
{
"date": "2026-01-15",
"time": "04:00",
"country": "US",
"name": "FOMC 금리 결정",
"importance": 3,
"forecast": "4.50%",
"previous": "4.50%",
"actual": null
},
{
"date": "2026-01-20",
"time": "22:30",
"country": "US",
"name": "GDP (QoQ)",
"importance": 3,
"forecast": "2.5%",
"previous": "2.8%",
"actual": null
}
]

View File

@@ -11,8 +11,12 @@
<div class="container">
<header class="header">
<div class="title-section">
<h1>BTC/USDT Real-time Trading View</h1>
<select id="timeframe">
<h1>BTC/USDT</h1>
<nav class="tab-nav">
<button class="tab-btn active" data-tab="chart">차트</button>
<button class="tab-btn" data-tab="calendar">경제일정</button>
</nav>
<select id="timeframe" class="chart-only">
<option value="1m">1분</option>
<option value="5m">5분</option>
<option value="15m" selected>15분</option>
@@ -23,14 +27,19 @@
<option value="1M">1월</option>
</select>
</div>
<div class="header-events" id="header-events">
<span class="header-events-loading">일정 로딩 중...</span>
</div>
<div class="price-info">
<span id="current-price">--</span>
<span id="price-change">--</span>
</div>
</header>
<div class="main-content">
<div class="charts-container">
<!-- Chart Tab Content -->
<div class="tab-content active" id="chart-tab">
<div class="main-content">
<div class="charts-container">
<!-- Main Chart with Candlestick, MA, Bollinger Bands -->
<div class="chart-wrapper main-chart">
<div class="chart-header">
@@ -145,6 +154,46 @@
</div>
</div>
<!-- Weekly Calendar Summary (Bottom Bar) -->
<div class="weekly-summary">
<div class="weekly-summary-header">
<span class="weekly-icon">📅</span>
<span class="weekly-title">이번주 주요 일정</span>
<button class="weekly-more-btn" id="show-calendar-btn">더보기 &gt;</button>
</div>
<div class="weekly-events" id="weekly-events">
<span class="loading-text">일정 로딩 중...</span>
</div>
</div>
</div>
<!-- Economic Calendar Tab Content -->
<div class="tab-content" id="calendar-tab">
<div class="calendar-container">
<!-- Filters -->
<div class="calendar-filters">
<div class="country-filters">
<button class="country-btn active" data-country="all">전체</button>
<button class="country-btn" data-country="US">🇺🇸 미국</button>
<button class="country-btn" data-country="KR">🇰🇷 한국</button>
<button class="country-btn" data-country="CN">🇨🇳 중국</button>
<button class="country-btn" data-country="JP">🇯🇵 일본</button>
</div>
<div class="period-filter">
<select id="calendar-period">
<option value="week" selected>이번주</option>
<option value="month">이번달</option>
</select>
</div>
</div>
<!-- Calendar List -->
<div class="calendar-list" id="calendar-list">
<div class="loading-text">경제 일정 로딩 중...</div>
</div>
</div>
</div>
</div>
<script src="indicators.js"></script>

156
n8n/README.md Normal file
View File

@@ -0,0 +1,156 @@
# n8n 경제 일정 워크플로우 설정 가이드
## 워크플로우 구성
두 개의 워크플로우로 구성됩니다:
1. **Economic Calendar Collector** - 주기적으로 실제 데이터 수집
2. **Economic Calendar API** - Webhook으로 데이터 제공
```
[Schedule: 6시간마다] → [HTTP: Forex Factory] → [Code: 변환] → [File: 저장]
[Webhook 요청] → [File: 읽기] → [Code: 파싱] → [Response: JSON]
```
---
## 설정 방법
### 1. 데이터 수집 워크플로우 설정
1. n8n 웹 UI 접속 (예: `https://n8n.yourdomain.com`)
2. **Workflows****Import from File**
3. `economic-calendar-collector.json` 파일 선택
4. **Save** 클릭
5. **Active** 토글을 켜서 워크플로우 활성화
6. 수동으로 한번 실행하여 초기 데이터 생성 (▶ 버튼 클릭)
### 2. API 워크플로우 설정
1. **Workflows****Import from File**
2. `economic-calendar-api.json` 파일 선택
3. **Save** 클릭
4. **Active** 토글을 켜서 워크플로우 활성화
### 3. Webhook URL 확인
API 워크플로우 활성화 후, Webhook 노드를 클릭하면 Production URL이 표시됩니다:
```
https://n8n.yourdomain.com/webhook/economic-calendar
```
### 4. 프론트엔드 설정 수정
`app.js`에서 `dataUrl`을 n8n webhook URL로 변경:
```javascript
this.dataUrl = 'https://n8n.yourdomain.com/webhook/economic-calendar';
```
---
## 데이터 소스
현재 **Investing.com**의 경제 캘린더 API를 사용합니다:
- URL: `https://sslecal2.investing.com/economic-calendar/Service/getCalendarFilteredData`
- 제공 데이터: 향후 30일간 경제 일정
- 필터 국가: US(미국), JP(일본), KR(한국), CN(중국)
- 필터 중요도: 2성, 3성 이벤트만
- 시간대: KST (한국 표준시, timeZone=88)
### 데이터 수집 주기
기본값: **매일 새벽 3시 (KST)** 자동 수집
Cron 표현식: `0 3 * * *`
변경하려면 Collector 워크플로우의 Schedule Trigger 설정 수정:
- 매일 새벽 3시: `0 3 * * *`
- 매일 자정: `0 0 * * *`
- 6시간마다: `field: "hours"`, `hoursInterval: 6`
---
## 파일 저장 경로
n8n Docker 컨테이너 내부: `/data/economic-calendar.json`
Docker Compose 볼륨 매핑 필요:
```yaml
volumes:
- n8n_data:/home/node/.n8n
- ./data:/data # 추가
```
---
## 대체 워크플로우 (수동 관리용)
실제 API 대신 수동으로 데이터를 관리하려면 기존 `economic-calendar-workflow.json` 사용:
1. `economic-calendar-workflow.json` import
2. Code 노드에서 직접 이벤트 데이터 수정
3. 활성화
---
## 일정 데이터 형식
```json
{
"date": "2025-01-15", // YYYY-MM-DD 형식
"time": "04:00", // HH:MM 형식 (한국시간)
"country": "US", // US, KR, CN, JP
"name": "FOMC 금리 결정", // 일정 이름
"importance": 3, // 1: 낮음, 2: 중간, 3: 높음
"forecast": "4.50%", // 예상치
"previous": "4.50%", // 이전값
"actual": null // 실제값 (발표 전: null)
}
```
---
## 주요 경제 일정 목록 (참고용)
### 미국 (US)
| 일정 | 중요도 | 발표 주기 |
|------|--------|-----------|
| FOMC 금리 결정 | ★★★ | 6주마다 |
| 비농업 고용지수 (NFP) | ★★★ | 매월 첫째 금요일 |
| CPI (소비자물가지수) | ★★★ | 매월 |
| GDP | ★★★ | 분기 |
| ISM 제조업 PMI | ★★★ | 매월 |
| CB 소비자신뢰지수 | ★★★ | 매월 |
### 한국 (KR)
| 일정 | 중요도 | 발표 주기 |
|------|--------|-----------|
| 한국은행 기준금리 | ★★★ | 연 8회 |
| CPI | ★★★ | 매월 |
| 산업생산 | ★★ | 매월 |
| 수출입 | ★★ | 매월 |
### 중국 (CN)
| 일정 | 중요도 | 발표 주기 |
|------|--------|-----------|
| 제조업 PMI | ★★★ | 매월 |
| Caixin 제조업 PMI | ★★★ | 매월 |
| GDP | ★★★ | 분기 |
### 일본 (JP)
| 일정 | 중요도 | 발표 주기 |
|------|--------|-----------|
| 일본은행 금리 결정 | ★★★ | 연 8회 |
| GDP | ★★★ | 분기 |
| 실업률 | ★★ | 매월 |
---
## 경제 일정 데이터 소스
- [Investing.com 경제 캘린더](https://kr.investing.com/economic-calendar/)
- [TradingEconomics](https://tradingeconomics.com/calendar)
- [Forex Factory](https://www.forexfactory.com/calendar)

View File

@@ -0,0 +1,156 @@
{
"name": "Economic Calendar (Combined)",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 3 * * *"
}
]
}
},
"id": "schedule-1",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [250, 200]
},
{
"parameters": {
"httpMethod": "GET",
"path": "economic-calendar",
"authentication": "headerAuth",
"responseMode": "responseNode",
"options": {}
},
"id": "webhook-1",
"name": "API Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [250, 400],
"webhookId": "economic-calendar",
"credentials": {
"httpHeaderAuth": {
"id": "economic-calendar-api-key",
"name": "Economic Calendar API Key"
}
}
},
{
"parameters": {
"method": "GET",
"url": "https://nfs.faireconomy.media/ff_calendar_thisweek.json",
"options": {}
},
"id": "http-1",
"name": "Fetch Forex Factory",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [470, 200]
},
{
"parameters": {
"jsCode": "// Forex Factory 데이터 변환 및 DB 저장\nconst rawData = $input.all();\nconst staticData = $getWorkflowStaticData('global');\n\nconst newEvents = [];\n\nconst countryMap = {\n 'USD': 'US',\n 'JPY': 'JP',\n 'CNY': 'CN'\n};\n\nconst importanceMap = {\n 'Low': 1,\n 'Medium': 2,\n 'High': 3\n};\n\nconst getLocalDateStr = (d) => {\n const y = d.getFullYear();\n const m = String(d.getMonth() + 1).padStart(2, '0');\n const day = String(d.getDate()).padStart(2, '0');\n return `${y}-${m}-${day}`;\n};\n\nconst convertToKST = (dateStr) => {\n const d = new Date(dateStr);\n // UTC 기준으로 KST(+9) 변환 - 서버 timezone 무관\n const kstTime = new Date(d.getTime() + 9 * 60 * 60 * 1000);\n return {\n date: `${kstTime.getUTCFullYear()}-${String(kstTime.getUTCMonth() + 1).padStart(2, '0')}-${String(kstTime.getUTCDate()).padStart(2, '0')}`,\n time: `${String(kstTime.getUTCHours()).padStart(2, '0')}:${String(kstTime.getUTCMinutes()).padStart(2, '0')}`\n };\n};\n\nfor (const item of rawData) {\n const event = item.json;\n const country = countryMap[event.country];\n \n if (!country || !['US', 'JP', 'CN'].includes(country)) continue;\n if (event.impact === 'Low') continue;\n \n const kst = convertToKST(event.date);\n \n newEvents.push({\n date: kst.date,\n time: kst.time,\n country: country,\n name: event.title,\n importance: importanceMap[event.impact] || 2,\n forecast: event.forecast || '-',\n previous: event.previous || '-',\n actual: event.actual || null\n });\n}\n\nnewEvents.sort((a, b) => {\n const dateCompare = a.date.localeCompare(b.date);\n if (dateCompare !== 0) return dateCompare;\n return a.time.localeCompare(b.time);\n});\n\n// Static Data에 저장\nstaticData.events = newEvents;\nstaticData.updated = new Date().toISOString();\nstaticData.count = newEvents.length;\n\nreturn [{\n json: {\n success: true,\n message: 'Data saved to n8n database',\n count: newEvents.length,\n updated: staticData.updated\n }\n}];"
},
"id": "code-save",
"name": "Transform & Save",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [690, 200]
},
{
"parameters": {
"jsCode": "// Static Data에서 이벤트 읽기 (없으면 직접 fetch)\nconst staticData = $getWorkflowStaticData('global');\n\n// 캐시 유효 시간 (6시간)\nconst CACHE_TTL = 6 * 60 * 60 * 1000;\nconst now = Date.now();\nconst lastUpdated = staticData.updated ? new Date(staticData.updated).getTime() : 0;\nconst isCacheValid = staticData.events?.length > 0 && (now - lastUpdated) < CACHE_TTL;\n\nif (isCacheValid) {\n return [{ json: {\n data: staticData.events,\n source: 'forexfactory',\n updated: staticData.updated,\n count: staticData.count,\n cached: true\n }}];\n}\n\n// 캐시가 없거나 만료된 경우 직접 fetch (n8n $http 사용)\nconst response = await this.helpers.httpRequest({\n method: 'GET',\n url: 'https://nfs.faireconomy.media/ff_calendar_thisweek.json',\n json: true\n});\nconst rawData = response;\n\nconst countryMap = { 'USD': 'US', 'JPY': 'JP', 'CNY': 'CN' };\nconst importanceMap = { 'Low': 1, 'Medium': 2, 'High': 3 };\n\nconst getLocalDateStr = (d) => {\n const y = d.getFullYear();\n const m = String(d.getMonth() + 1).padStart(2, '0');\n const day = String(d.getDate()).padStart(2, '0');\n return `${y}-${m}-${day}`;\n};\n\nconst convertToKST = (dateStr) => {\n const d = new Date(dateStr);\n // UTC 기준으로 KST(+9) 변환 - 서버 timezone 무관\n const kstTime = new Date(d.getTime() + 9 * 60 * 60 * 1000);\n return {\n date: `${kstTime.getUTCFullYear()}-${String(kstTime.getUTCMonth() + 1).padStart(2, '0')}-${String(kstTime.getUTCDate()).padStart(2, '0')}`,\n time: `${String(kstTime.getUTCHours()).padStart(2, '0')}:${String(kstTime.getUTCMinutes()).padStart(2, '0')}`\n };\n};\n\nconst newEvents = [];\nfor (const event of rawData) {\n const country = countryMap[event.country];\n if (!country || !['US', 'JP', 'CN'].includes(country)) continue;\n if (event.impact === 'Low') continue;\n \n const kst = convertToKST(event.date);\n newEvents.push({\n date: kst.date,\n time: kst.time,\n country: country,\n name: event.title,\n importance: importanceMap[event.impact] || 2,\n forecast: event.forecast || '-',\n previous: event.previous || '-',\n actual: event.actual || null\n });\n}\n\nnewEvents.sort((a, b) => {\n const dateCompare = a.date.localeCompare(b.date);\n if (dateCompare !== 0) return dateCompare;\n return a.time.localeCompare(b.time);\n});\n\n// 캐시에 저장\nstaticData.events = newEvents;\nstaticData.updated = new Date().toISOString();\nstaticData.count = newEvents.length;\n\nreturn [{ json: {\n data: newEvents,\n source: 'forexfactory',\n updated: staticData.updated,\n count: newEvents.length,\n cached: false\n}}];"
},
"id": "code-read",
"name": "Read Data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [470, 400]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {
"responseHeaders": {
"entries": [
{
"name": "Access-Control-Allow-Origin",
"value": "*"
},
{
"name": "Cache-Control",
"value": "max-age=300"
}
]
}
}
},
"id": "respond-1",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [690, 400]
}
],
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "Fetch Forex Factory",
"type": "main",
"index": 0
}
]
]
},
"API Webhook": {
"main": [
[
{
"node": "Read Data",
"type": "main",
"index": 0
}
]
]
},
"Fetch Forex Factory": {
"main": [
[
{
"node": "Transform & Save",
"type": "main",
"index": 0
}
]
]
},
"Read Data": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": {
"global": {
"events": [],
"updated": null,
"count": 0
}
}
}

View File

@@ -34,8 +34,53 @@ body {
}
.header h1 {
font-size: 1.5rem;
font-size: 1.3rem;
color: #f0b90b;
white-space: nowrap;
}
/* Tab Navigation */
.tab-nav {
display: flex;
gap: 5px;
background: #2a2e39;
padding: 4px;
border-radius: 8px;
}
.tab-btn {
padding: 8px 16px;
background: transparent;
border: none;
color: #848e9c;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.tab-btn:hover {
color: #d1d4dc;
}
.tab-btn.active {
background: #f0b90b;
color: #131722;
}
/* Tab Content */
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Hide chart-only elements in calendar tab */
.chart-only {
transition: opacity 0.2s;
}
.controls {
@@ -58,6 +103,71 @@ select:hover {
border-color: #f0b90b;
}
/* Header Events (Today's Schedule) */
.header-events {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex: 1;
justify-content: flex-start;
padding-left: 15px;
}
.header-events-loading {
color: #848e9c;
font-size: 12px;
}
.header-event-item {
display: flex;
align-items: center;
gap: 6px;
background: #2a2e39;
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
}
.header-event-item.importance-3 {
border-left: 3px solid #f0b90b;
}
.header-event-item.importance-2 {
border-left: 3px solid #848e9c;
}
.header-event-item.importance-1 {
border-left: 3px solid #4a4e59;
}
.header-event-time {
color: #848e9c;
font-size: 11px;
min-width: 35px;
}
.header-event-country {
font-size: 14px;
}
.header-event-name {
color: #d1d4dc;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.header-event-stars {
color: #f0b90b;
font-size: 10px;
}
.header-events-empty {
color: #6c7284;
font-size: 12px;
}
.price-info {
display: flex;
flex-direction: column;
@@ -495,6 +605,555 @@ select:hover {
to { transform: rotate(360deg); }
}
/* Weekly Summary Bar */
.weekly-summary {
background: #1e222d;
border-radius: 8px;
padding: 12px 15px;
margin-top: 10px;
}
.weekly-summary-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.weekly-icon {
font-size: 16px;
}
.weekly-title {
font-size: 14px;
font-weight: 600;
color: #f0b90b;
flex: 1;
}
.weekly-more-btn {
background: transparent;
border: 1px solid #363a45;
color: #848e9c;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.weekly-more-btn:hover {
border-color: #f0b90b;
color: #f0b90b;
}
.weekly-events {
display: flex;
gap: 15px;
overflow-x: auto;
padding-bottom: 5px;
}
.weekly-event-item {
display: flex;
align-items: center;
gap: 8px;
background: #2a2e39;
padding: 8px 12px;
border-radius: 6px;
white-space: nowrap;
flex-shrink: 0;
}
.weekly-event-date {
font-size: 12px;
color: #848e9c;
}
.weekly-event-country {
font-size: 14px;
}
.weekly-event-name {
font-size: 13px;
color: #d1d4dc;
}
.weekly-event-importance {
color: #f0b90b;
font-size: 10px;
}
/* Economic Calendar Tab */
.calendar-container {
padding: 10px 0;
}
.calendar-filters {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 10px;
}
.country-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.country-btn {
padding: 8px 14px;
background: #2a2e39;
border: 1px solid #363a45;
color: #848e9c;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.country-btn:hover {
border-color: #f0b90b;
color: #d1d4dc;
}
.country-btn.active {
background: #f0b90b;
border-color: #f0b90b;
color: #131722;
}
.period-filter select {
padding: 8px 15px;
background: #2a2e39;
border: 1px solid #363a45;
border-radius: 6px;
color: #d1d4dc;
font-size: 13px;
cursor: pointer;
}
/* Calendar List */
.calendar-list {
background: #1e222d;
border-radius: 8px;
overflow: hidden;
}
.calendar-date-group {
border-bottom: 1px solid #2a2e39;
}
.calendar-date-group:last-child {
border-bottom: none;
}
.calendar-date-header {
background: #2a2e39;
padding: 10px 15px;
font-size: 14px;
font-weight: 600;
color: #f0b90b;
}
.calendar-event {
display: flex;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid #2a2e39;
gap: 12px;
}
.calendar-event:last-child {
border-bottom: none;
}
.calendar-event:hover {
background: #252930;
}
.event-time {
font-size: 13px;
color: #848e9c;
min-width: 50px;
}
.event-country {
font-size: 18px;
min-width: 30px;
}
.event-info {
flex: 1;
}
.event-name {
font-size: 14px;
color: #d1d4dc;
margin-bottom: 3px;
}
.event-detail {
font-size: 12px;
color: #6c7284;
}
.event-importance {
display: flex;
gap: 2px;
}
.importance-star {
color: #363a45;
font-size: 12px;
}
.importance-star.active {
color: #f0b90b;
}
.event-values {
display: flex;
gap: 15px;
min-width: 200px;
justify-content: flex-end;
}
.event-value {
text-align: right;
}
.event-value-label {
font-size: 10px;
color: #6c7284;
text-transform: uppercase;
}
.event-value-number {
font-size: 13px;
color: #d1d4dc;
font-weight: 500;
}
.event-value-number.positive {
color: #26a69a;
}
.event-value-number.negative {
color: #ef5350;
}
.loading-text {
padding: 30px;
text-align: center;
color: #848e9c;
}
/* Monthly Calendar Grid */
.calendar-grid {
background: #1e222d;
border-radius: 8px;
overflow: hidden;
}
.calendar-grid-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background: #2a2e39;
}
.calendar-month-title {
font-size: 16px;
font-weight: 600;
color: #f0b90b;
}
.calendar-nav {
display: flex;
gap: 10px;
}
.calendar-nav-btn {
background: #363a45;
border: none;
color: #d1d4dc;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.calendar-nav-btn:hover {
background: #4a4e59;
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: #252930;
border-bottom: 1px solid #363a45;
}
.calendar-weekday {
padding: 10px;
text-align: center;
font-size: 12px;
font-weight: 600;
color: #848e9c;
}
.calendar-weekday:first-child {
color: #ef5350;
}
.calendar-weekday:last-child {
color: #2196f3;
}
.calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.calendar-day {
min-height: 120px;
padding: 8px;
border-right: 1px solid #2a2e39;
border-bottom: 1px solid #2a2e39;
background: #1e222d;
overflow: hidden;
}
.calendar-day:nth-child(7n) {
border-right: none;
}
.calendar-day.other-month {
background: #171b24;
}
.calendar-day.other-month .day-number {
color: #4a4e59;
}
.calendar-day.today {
background: #252930;
}
.calendar-day.today .day-number {
background: #f0b90b;
color: #131722;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.day-number {
font-size: 12px;
color: #d1d4dc;
margin-bottom: 5px;
}
.calendar-day:nth-child(7n+1) .day-number {
color: #ef5350;
}
.calendar-day:nth-child(7n) .day-number {
color: #2196f3;
}
.calendar-day.other-month:nth-child(7n+1) .day-number,
.calendar-day.other-month:nth-child(7n) .day-number {
color: #4a4e59;
}
.day-events {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
overflow: hidden;
}
.day-event {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 4px;
background: #2a2e39;
border-radius: 3px;
font-size: 10px;
color: #d1d4dc;
overflow: hidden;
min-width: 0;
cursor: pointer;
}
.day-event:hover {
background: #363a45;
}
.day-event.importance-3 {
border-left: 2px solid #f0b90b;
}
.day-event.importance-2 {
border-left: 2px solid #848e9c;
}
.day-event.importance-1 {
border-left: 2px solid #4a4e59;
}
.day-event-time {
font-size: 9px;
color: #848e9c;
min-width: 32px;
}
.day-event-country {
font-size: 11px;
}
.day-event-name {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.day-event-stars {
font-size: 8px;
color: #f0b90b;
margin-left: 2px;
}
.day-event-more {
font-size: 10px;
color: #848e9c;
padding: 2px 4px;
cursor: pointer;
}
.day-event-more:hover {
color: #f0b90b;
}
/* Events Modal */
.events-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.events-modal {
background: #1e222d;
border-radius: 12px;
min-width: 320px;
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
border: 1px solid #2a2e39;
}
.events-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #252930;
border-bottom: 1px solid #2a2e39;
}
.events-modal-header h3 {
margin: 0;
font-size: 16px;
color: #d1d4dc;
}
.events-modal-close {
background: none;
border: none;
color: #848e9c;
font-size: 24px;
cursor: pointer;
padding: 0;
line-height: 1;
}
.events-modal-close:hover {
color: #f0b90b;
}
.events-modal-body {
padding: 16px 20px;
max-height: 60vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.modal-event {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: #2a2e39;
border-radius: 6px;
font-size: 13px;
color: #d1d4dc;
}
.modal-event.importance-3 {
border-left: 3px solid #f0b90b;
}
.modal-event.importance-2 {
border-left: 3px solid #848e9c;
}
.modal-event.importance-1 {
border-left: 3px solid #4a4e59;
}
.modal-event-time {
color: #848e9c;
font-size: 12px;
min-width: 40px;
}
.modal-event-country {
font-size: 16px;
}
.modal-event-name {
flex: 1;
color: #d1d4dc;
}
.modal-event-stars {
color: #f0b90b;
font-size: 11px;
}
/* Responsive */
@media (max-width: 768px) {
.header {
@@ -502,10 +1161,26 @@ select:hover {
gap: 10px;
}
.title-section {
flex-direction: column;
gap: 10px;
width: 100%;
}
.header h1 {
font-size: 1.2rem;
}
.header-events {
max-width: 100%;
justify-content: flex-start;
padding: 0;
}
.header-event-name {
max-width: 80px;
}
#main-chart {
height: 350px;
}
@@ -520,4 +1195,14 @@ select:hover {
flex-wrap: wrap;
gap: 8px;
}
.calendar-event {
flex-wrap: wrap;
}
.event-values {
width: 100%;
justify-content: flex-start;
margin-top: 8px;
}
}