Add economic calendar feature with n8n integration
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
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:
54
README.md
54
README.md
@@ -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
621
app.js
@@ -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">< 이전</button>
|
||||
</div>
|
||||
<div class="calendar-month-title">${monthTitle}</div>
|
||||
<div class="calendar-nav">
|
||||
<button class="calendar-nav-btn" id="next-month">다음 ></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">×</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
132
data/economic-calendar.json
Normal 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
|
||||
}
|
||||
]
|
||||
57
index.html
57
index.html
@@ -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">더보기 ></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
156
n8n/README.md
Normal 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)
|
||||
156
n8n/economic-calendar-combined.json
Normal file
156
n8n/economic-calendar-combined.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
687
styles.css
687
styles.css
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user