From 022fddec9c498130872aba1f3ed9b8c35ed2754c Mon Sep 17 00:00:00 2001 From: "kihong.kim" Date: Wed, 31 Dec 2025 15:24:30 +0900 Subject: [PATCH] Add economic calendar feature with n8n integration - 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 --- README.md | 54 ++- app.js | 621 +++++++++++++++++++++++++ data/economic-calendar.json | 132 ++++++ index.html | 57 ++- n8n/README.md | 156 +++++++ n8n/economic-calendar-combined.json | 156 +++++++ styles.css | 687 +++++++++++++++++++++++++++- 7 files changed, 1850 insertions(+), 13 deletions(-) create mode 100644 data/economic-calendar.json create mode 100644 n8n/README.md create mode 100644 n8n/economic-calendar-combined.json diff --git a/README.md b/README.md index e5988e8..bc2644a 100644 --- a/README.md +++ b/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 ``` ## 스크린샷 diff --git a/app.js b/app.js index 7c7509a..4fea449 100644 --- a/app.js +++ b/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 = '오늘 주요 일정 없음'; + return; + } + + container.innerHTML = todayEvents.map(event => { + const stars = '★'.repeat(event.importance); + return ` +
+ ${event.time} + ${this.getCountryFlag(event.country)} + ${event.name} + ${stars} +
+ `; + }).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 = '이번주 주요 일정이 없습니다'; + 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 ` +
+ ${dateStr} + ${this.getCountryFlag(event.country)} + ${event.name} + ${stars} +
+ `; + }).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 = '
해당 기간에 일정이 없습니다
'; + 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 => + `` + ).join(''); + + return ` +
+ ${event.time} + ${this.getCountryFlag(event.country)} +
+
${event.name}
+
+
${stars}
+
+
+
예상
+
${event.forecast || '-'}
+
+
+
이전
+
${event.previous || '-'}
+
+
+
실제
+
${event.actual || '-'}
+
+
+
+ `; + }).join(''); + + return ` +
+
${dateStr}
+ ${eventsHtml} +
+ `; + }).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 ` +
+ ${event.time} + ${this.getCountryFlag(event.country)} + ${event.name} + ${stars} +
+ `; + }).join(''); + + const moreHtml = moreCount > 0 ? `
+${moreCount}개 더보기
` : ''; + + daysHtml += ` +
+
${currentDate.getDate()}
+
+ ${eventsHtml} + ${moreHtml} +
+
+ `; + + currentDate.setDate(currentDate.getDate() + 1); + } + + container.innerHTML = ` +
+
+
+ +
+
${monthTitle}
+
+ +
+
+
+ ${weekdays.map(day => `
${day}
`).join('')} +
+
+ ${daysHtml} +
+
+ `; + + // 네비게이션 버튼 이벤트 + 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 ` + + `; + }).join(''); + + // 모달 생성 + const modal = document.createElement('div'); + modal.className = 'events-modal-overlay'; + modal.innerHTML = ` +
+
+

${dateTitle} 일정

+ +
+
+ ${eventsHtml} +
+
+ `; + + 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(); }); diff --git a/data/economic-calendar.json b/data/economic-calendar.json new file mode 100644 index 0000000..33c42d3 --- /dev/null +++ b/data/economic-calendar.json @@ -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 + } +] diff --git a/index.html b/index.html index d18a977..04d9a60 100644 --- a/index.html +++ b/index.html @@ -11,8 +11,12 @@
-

BTC/USDT Real-time Trading View

- @@ -23,14 +27,19 @@
+
+ 일정 로딩 중... +
-- --
-
-
+ +
+
+
@@ -145,6 +154,46 @@
+ +
+
+ 📅 + 이번주 주요 일정 + +
+
+ 일정 로딩 중... +
+
+
+ + +
+
+ +
+
+ + + + + +
+
+ +
+
+ + +
+
경제 일정 로딩 중...
+
+
+
+
diff --git a/n8n/README.md b/n8n/README.md new file mode 100644 index 0000000..aa3b32a --- /dev/null +++ b/n8n/README.md @@ -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) diff --git a/n8n/economic-calendar-combined.json b/n8n/economic-calendar-combined.json new file mode 100644 index 0000000..6654dc0 --- /dev/null +++ b/n8n/economic-calendar-combined.json @@ -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 + } + } +} diff --git a/styles.css b/styles.css index 56ac082..1eba7c7 100644 --- a/styles.css +++ b/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; + } }