/** * weather.js - A module for weather-related functionality * Provides real-time weather data and sunset/sunrise information * Uses OpenWeatherMap API for weather data */ class WeatherManager { constructor(options = {}) { // Default options // Get API key from options, window (injected by server from .env), or fallback const apiKey = options.apiKey || window.OPENWEATHERMAP_API_KEY || ''; this.options = { latitude: options.latitude || (window.DEFAULT_LOCATION?.latitude) || 59.3293, // Stockholm latitude longitude: options.longitude || (window.DEFAULT_LOCATION?.longitude) || 18.0686, // Stockholm longitude apiKey: apiKey, // OpenWeatherMap API key (from .env via server injection, or fallback) refreshInterval: options.refreshInterval || 30 * 60 * 1000, // 30 minutes in milliseconds ...options }; // State this.weatherData = null; this.forecastData = null; this.sunTimes = null; this.isDarkMode = false; this.lastUpdated = null; this.daylightBarUpdateInterval = null; // Initialize this.init(); } /** * Initialize the weather manager */ async init() { try { // Check for API key if (!this.options.apiKey) { console.warn('WeatherManager: No OpenWeatherMap API key configured. Set OPENWEATHERMAP_API_KEY in your .env file.'); const weatherContainer = document.getElementById('custom-weather'); if (weatherContainer) { const warningEl = document.createElement('div'); warningEl.style.cssText = 'padding: 10px; color: #c41e3a; font-size: 0.9em; text-align: center;'; warningEl.textContent = 'Weather unavailable: No API key configured. Set OPENWEATHERMAP_API_KEY in .env'; weatherContainer.prepend(warningEl); } // Still set up sun times from calculation so dark mode works await this.updateSunTimesFromCalculation(); this.updateDarkModeBasedOnTime(); this.dispatchDarkModeEvent(); return; } // Fetch weather data await this.fetchWeatherData(); // Check if it's dark outside (only affects auto mode) this.updateDarkModeBasedOnTime(); // Set up interval to check dark mode every minute (only affects auto mode) this.darkModeCheckInterval = setInterval(() => { // Only update dark mode based on time if ConfigManager has dark mode set to 'auto' if (this.shouldUseAutoDarkMode()) { this.updateDarkModeBasedOnTime(); } }, 60000); // Set up interval to refresh weather data setInterval(() => this.fetchWeatherData(), this.options.refreshInterval); // Dispatch initial dark mode state this.dispatchDarkModeEvent(); console.log('WeatherManager initialized'); } catch (error) { console.error('Error initializing WeatherManager:', error); // Fallback to calculated sun times if API fails await this.updateSunTimesFromCalculation(); this.updateDarkModeBasedOnTime(); this.dispatchDarkModeEvent(); } } /** * Check if we should use automatic dark mode based on ConfigManager settings */ shouldUseAutoDarkMode() { // If there's a ConfigManager instance with a config if (window.configManager && window.configManager.config) { // Only use auto dark mode if the setting is 'auto' return window.configManager.config.darkMode === 'auto'; } // Default to true if no ConfigManager is available return true; } /** * Fetch weather data from OpenWeatherMap API */ async fetchWeatherData() { try { // Fetch current weather (lang=se for Swedish descriptions) const currentWeatherUrl = `https://api.openweathermap.org/data/2.5/weather?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&lang=se&appid=${this.options.apiKey}`; const currentWeatherResponse = await fetch(currentWeatherUrl); const currentWeatherData = await currentWeatherResponse.json(); if (currentWeatherData.cod !== 200) { throw new Error(`API Error: ${currentWeatherData.message}`); } // Fetch 3-hour interval forecast (cnt=8 limits to ~24h of data) const forecastUrl = `https://api.openweathermap.org/data/2.5/forecast?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&lang=se&cnt=8&appid=${this.options.apiKey}`; const forecastResponse = await fetch(forecastUrl); const forecastData = await forecastResponse.json(); if (forecastData.cod !== "200") { throw new Error(`API Error: ${forecastData.message}`); } // Process and store the data this.weatherData = this.processCurrentWeather(currentWeatherData); this.forecastData = this.processForecast(forecastData); this.lastUpdated = new Date(); // Extract sunrise and sunset times from the API response await this.updateSunTimesFromApi(currentWeatherData); // Update the UI with the new data this.updateWeatherUI(); console.log('Weather data updated:', this.weatherData); return this.weatherData; } catch (error) { console.error('Error fetching weather data:', error); // If we don't have any weather data yet, create some default data if (!this.weatherData) { this.weatherData = this.createDefaultWeatherData(); this.forecastData = this.createDefaultForecastData(); } // Fallback to calculated sun times await this.updateSunTimesFromCalculation(); return this.weatherData; } } /** * Process current weather data from API response */ processCurrentWeather(data) { const iconCode = data.weather[0].icon; return { temperature: Math.round(data.main.temp * 10) / 10, // Round to 1 decimal place condition: data.weather[0].main, description: data.weather[0].description, icon: this.getWeatherIconUrl(iconCode), iconCode: iconCode, // Store icon code for classification wind: { speed: Math.round(data.wind.speed * 3.6), // Convert m/s to km/h direction: data.wind.deg }, humidity: data.main.humidity, pressure: data.main.pressure, precipitation: data.rain ? (data.rain['1h'] || 0) : 0, location: data.name, country: data.sys.country, timestamp: new Date(data.dt * 1000) }; } /** * Process forecast data from API response */ processForecast(data) { // Get the next 7 forecast periods (3-hour intervals, covering ~21 hours) return data.list.slice(0, 7).map(item => { const iconCode = item.weather[0].icon; return { temperature: Math.round(item.main.temp * 10) / 10, condition: item.weather[0].main, description: item.weather[0].description, icon: this.getWeatherIconUrl(iconCode), iconCode: iconCode, // Store icon code for classification timestamp: new Date(item.dt * 1000), precipitation: item.rain ? (item.rain['3h'] || 0) : 0 }; }); } /** * Get weather icon URL from icon code */ getWeatherIconUrl(iconCode) { return `https://openweathermap.org/img/wn/${iconCode}@4x.png`; } /** * Classify a weather icon and return the appropriate CSS classes * @param {string} iconCode - OWM icon code (e.g. '01d', '13n') * @param {string} condition - Weather condition text (e.g. 'Clear', 'Clouds') * @returns {string[]} Array of CSS class names to apply */ classifyWeatherIcon(iconCode, condition) { const code = iconCode ? iconCode.replace(/[dn]$/, '') : ''; // Snow: icon 13x or condition contains 'Snow' if (code === '13' || condition.includes('Snow')) { return ['weather-snow']; } // Clear sun: icon 01x or condition is exactly 'Clear' if (code === '01' || condition === 'Clear') { return ['weather-sun', 'weather-clear-sun']; } // Sun behind clouds: icon 02-04x or cloudy condition if (['02', '03', '04'].includes(code) || (condition.includes('Clouds') && !condition.includes('Clear'))) { return ['weather-sun', 'weather-clouds-sun']; } return []; } /** * Apply weather icon CSS classes to an element */ applyWeatherIconClasses(element, iconCode, condition) { element.classList.remove('weather-sun', 'weather-snow', 'weather-clear-sun', 'weather-clouds-sun'); const classes = this.classifyWeatherIcon(iconCode, condition); if (classes.length > 0) { element.classList.add(...classes); } } /** * Create default weather data for fallback */ createDefaultWeatherData() { return { temperature: 7.1, condition: 'Clear', description: 'clear sky', icon: 'https://openweathermap.org/img/wn/01d@4x.png', iconCode: '01d', wind: { speed: 14.8, direction: 270 }, humidity: 65, pressure: 1012.0, precipitation: 0.00, location: 'Stockholm', country: 'SE', timestamp: new Date() }; } /** * Create default forecast data for fallback */ createDefaultForecastData() { const now = new Date(); const forecasts = []; // Create 7 forecast entries for (let i = 0; i < 7; i++) { const forecastTime = new Date(now); forecastTime.setHours(now.getHours() + i); forecasts.push({ temperature: 7.1 - (i * 0.3), // Decrease temperature slightly each hour condition: i < 2 ? 'Clear' : 'Clouds', description: i < 2 ? 'clear sky' : 'few clouds', icon: i < 2 ? 'https://openweathermap.org/img/wn/01n@4x.png' : 'https://openweathermap.org/img/wn/02n@4x.png', iconCode: i < 2 ? '01n' : '02n', timestamp: forecastTime, precipitation: 0 }); } return forecasts; } /** * Update the weather UI with current data */ updateWeatherUI() { if (!this.weatherData || !this.forecastData) return; try { // Update current weather const locationElement = document.querySelector('#custom-weather h3'); if (locationElement) { locationElement.textContent = this.weatherData.location; } const conditionElement = document.querySelector('#custom-weather .weather-icon div'); if (conditionElement) { conditionElement.textContent = this.weatherData.condition; } const iconElement = document.querySelector('#custom-weather .weather-icon img'); if (iconElement) { iconElement.src = this.weatherData.icon; iconElement.alt = this.weatherData.description; iconElement.setAttribute('data-condition', this.weatherData.condition); this.applyWeatherIconClasses(iconElement, this.weatherData.iconCode, this.weatherData.condition); } const temperatureElement = document.querySelector('#custom-weather .temperature'); if (temperatureElement) { temperatureElement.textContent = `${this.weatherData.temperature} °C`; } // Update forecast const forecastContainer = document.querySelector('#custom-weather .forecast'); if (forecastContainer) { // Clear existing forecast forecastContainer.innerHTML = ''; // Add current weather as "Nu" (Swedish for "Now") const nowElement = document.createElement('div'); nowElement.className = 'forecast-hour'; const nowIcon = document.createElement('img'); nowIcon.src = this.weatherData.icon; nowIcon.alt = this.weatherData.description; nowIcon.width = 56; nowIcon.setAttribute('data-condition', this.weatherData.condition); this.applyWeatherIconClasses(nowIcon, this.weatherData.iconCode, this.weatherData.condition); nowElement.innerHTML = `
Nu
${this.weatherData.temperature} °C
`; nowElement.querySelector('.icon').appendChild(nowIcon); forecastContainer.appendChild(nowElement); // Add hourly forecasts this.forecastData.forEach(forecast => { const forecastTime = forecast.timestamp; const timeString = forecastTime.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' }); const forecastElement = document.createElement('div'); forecastElement.className = 'forecast-hour'; const forecastIcon = document.createElement('img'); forecastIcon.src = forecast.icon; forecastIcon.alt = forecast.description; forecastIcon.width = 56; forecastIcon.setAttribute('data-condition', forecast.condition); this.applyWeatherIconClasses(forecastIcon, forecast.iconCode, forecast.condition); forecastElement.innerHTML = `
${timeString}
${forecast.temperature} °C
`; forecastElement.querySelector('.icon').appendChild(forecastIcon); forecastContainer.appendChild(forecastElement); }); } // Update sun times const sunTimesElement = document.querySelector('#custom-weather .sun-times'); if (sunTimesElement && this.sunTimes) { const sunriseTime = this.formatTime(this.sunTimes.today.sunrise); const sunsetTime = this.formatTime(this.sunTimes.today.sunset); sunTimesElement.textContent = `☀️ Sunrise: ${sunriseTime} | 🌙 Sunset: ${sunsetTime}`; } // Update daylight hours bar if (this.sunTimes) { this.updateDaylightHoursBar(); } } catch (error) { console.error('Error updating weather UI:', error); } } /** * Update sunrise and sunset times from API data */ async updateSunTimesFromApi(data) { if (!data || !data.sys || !data.sys.sunrise || !data.sys.sunset) { console.warn('No sunrise/sunset data in API response, using calculated times'); await this.updateSunTimesFromCalculation(); return; } try { // Create Date objects from Unix timestamps for today const sunrise = new Date(data.sys.sunrise * 1000); const sunset = new Date(data.sys.sunset * 1000); // Fetch tomorrow's times from sunrise-sunset.org API const tomorrowTimes = await this.fetchSunTimes('tomorrow'); this.sunTimes = { today: { sunrise, sunset }, tomorrow: tomorrowTimes }; console.log('Sun times updated from API:', this.sunTimes); return this.sunTimes; } catch (error) { console.error('Error updating sun times from API:', error); await this.updateSunTimesFromCalculation(); } } /** * Update sunrise and sunset times using sunrise-sunset.org API * Falls back to hardcoded defaults if the API is unreachable */ async updateSunTimesFromCalculation() { try { const [todayData, tomorrowData] = await Promise.all([ this.fetchSunTimes('today'), this.fetchSunTimes('tomorrow') ]); this.sunTimes = { today: todayData, tomorrow: tomorrowData }; console.log('Sun times updated from sunrise-sunset.org:', this.sunTimes); return this.sunTimes; } catch (error) { console.error('Error fetching sun times from API, using defaults:', error); const defaultSunrise = new Date(); defaultSunrise.setHours(7, 0, 0, 0); const defaultSunset = new Date(); defaultSunset.setHours(16, 0, 0, 0); this.sunTimes = { today: { sunrise: defaultSunrise, sunset: defaultSunset }, tomorrow: { sunrise: defaultSunrise, sunset: defaultSunset } }; return this.sunTimes; } } /** * Fetch sunrise/sunset times from sunrise-sunset.org API * @param {string} date - 'today', 'tomorrow', or YYYY-MM-DD * @returns {Object} { sunrise: Date, sunset: Date } */ async fetchSunTimes(date) { const url = `https://api.sunrise-sunset.org/json?lat=${this.options.latitude}&lng=${this.options.longitude}&date=${date}&formatted=0`; const response = await fetch(url); const data = await response.json(); if (data.status !== 'OK') { throw new Error(`Sunrise-sunset API returned status: ${data.status}`); } return { sunrise: new Date(data.results.sunrise), sunset: new Date(data.results.sunset) }; } /** * Check if it's currently dark outside based on sun times */ isDark() { if (!this.sunTimes) return false; const now = new Date(); const today = this.sunTimes.today; // Check if current time is after today's sunset or before today's sunrise return now > today.sunset || now < today.sunrise; } /** * Update dark mode state based on current time */ updateDarkModeBasedOnTime() { const wasDarkMode = this.isDarkMode; this.isDarkMode = this.isDark(); // If dark mode state changed, dispatch event if (wasDarkMode !== this.isDarkMode) { this.dispatchDarkModeEvent(); } } /** * Set dark mode state manually */ setDarkMode(isDarkMode) { if (this.isDarkMode !== isDarkMode) { this.isDarkMode = isDarkMode; this.dispatchDarkModeEvent(); } } /** * Toggle dark mode */ toggleDarkMode() { this.isDarkMode = !this.isDarkMode; this.dispatchDarkModeEvent(); return this.isDarkMode; } /** * Dispatch dark mode change event */ dispatchDarkModeEvent() { const event = new CustomEvent('darkModeChanged', { detail: { isDarkMode: this.isDarkMode, automatic: true } }); document.dispatchEvent(event); console.log('Dark mode ' + (this.isDarkMode ? 'enabled' : 'disabled')); } /** * Get formatted sunrise time */ getSunriseTime() { if (!this.sunTimes) return '06:45'; return this.formatTime(this.sunTimes.today.sunrise); } /** * Get formatted sunset time */ getSunsetTime() { if (!this.sunTimes) return '17:32'; return this.formatTime(this.sunTimes.today.sunset); } /** * Format time as HH:MM */ formatTime(date) { return date.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' }); } /** * Get the last updated time */ getLastUpdatedTime() { if (!this.lastUpdated) return 'Never'; return this.formatTime(this.lastUpdated); } /** * Render the daylight hours bar with gradient and current hour indicator */ renderDaylightHoursBar() { if (!this.sunTimes) return; const barElement = document.getElementById('daylight-hours-bar'); const backgroundElement = barElement?.querySelector('.daylight-bar-background'); const indicatorElement = barElement?.querySelector('.daylight-bar-indicator'); if (!barElement || !backgroundElement || !indicatorElement) return; const today = this.sunTimes.today; // Normalize sunrise and sunset to today's date for consistent calculation const now = new Date(); const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const sunrise = new Date(todayDate); sunrise.setHours(today.sunrise.getHours(), today.sunrise.getMinutes(), 0, 0); const sunset = new Date(todayDate); sunset.setHours(today.sunset.getHours(), today.sunset.getMinutes(), 0, 0); // Calculate positions as percentage of 24 hours (1440 minutes) // Extract hours and minutes from the date objects const getTimePosition = (date) => { const hours = date.getHours(); const minutes = date.getMinutes(); const totalMinutes = hours * 60 + minutes; return (totalMinutes / 1440) * 100; }; const sunrisePosition = getTimePosition(sunrise); const sunsetPosition = getTimePosition(sunset); const currentPosition = getTimePosition(now); // Ensure positions are valid (0-100) const clampPosition = (pos) => Math.max(0, Math.min(100, pos)); const sunrisePos = clampPosition(sunrisePosition); const sunsetPos = clampPosition(sunsetPosition); const currentPos = clampPosition(currentPosition); // Create modern gradient for daylight hours with smooth transitions // Multiple color stops for a more sophisticated gradient effect let gradient = ''; // Handle case where sunrise is before sunset (normal day) if (sunrisePos < sunsetPos) { // Create gradient with smooth transitions: // - Midnight blue (night) -> dark blue -> orange/red (dawn) -> yellow (day) -> orange/red (dusk) -> dark blue -> midnight blue (night) const dawnStart = Math.max(0, sunrisePos - 2); const dawnEnd = Math.min(100, sunrisePos + 1); const duskStart = Math.max(0, sunsetPos - 1); const duskEnd = Math.min(100, sunsetPos + 2); gradient = `linear-gradient(to right, #191970 0%, #191970 ${dawnStart}%, #2E3A87 ${dawnStart}%, #FF6B35 ${dawnEnd}%, #FFD93D ${Math.min(100, dawnEnd + 1)}%, #FFEB3B ${Math.min(100, dawnEnd + 1)}%, #FFEB3B ${duskStart}%, #FFD93D ${duskStart}%, #FF6B35 ${Math.max(0, duskEnd - 1)}%, #2E3A87 ${duskEnd}%, #191970 ${duskEnd}%, #191970 100%)`; } else { // Handle edge cases (polar day/night or sunrise after sunset near midnight) // For simplicity, show all as night (midnight blue) gradient = 'linear-gradient(to right, #191970 0%, #191970 100%)'; } // Apply gradient to background backgroundElement.style.backgroundImage = gradient; // Determine if it's day or night for icon const isDaytime = currentPos >= sunrisePos && currentPos <= sunsetPos; const iconElement = indicatorElement.querySelector('.sun-icon, .moon-icon'); if (iconElement) { iconElement.textContent = isDaytime ? '☀️' : '🌙'; // Update classes to match the icon for proper styling if (isDaytime) { iconElement.classList.remove('moon-icon'); iconElement.classList.add('sun-icon'); } else { iconElement.classList.remove('sun-icon'); iconElement.classList.add('moon-icon'); } } // Position current hour indicator indicatorElement.style.left = `${currentPos}%`; // Debug logging console.log('Daylight bar positions:', { sunrise: `${today.sunrise.getHours()}:${today.sunrise.getMinutes().toString().padStart(2, '0')}`, sunset: `${today.sunset.getHours()}:${today.sunset.getMinutes().toString().padStart(2, '0')}`, current: `${now.getHours()}:${now.getMinutes().toString().padStart(2, '0')}`, sunrisePos: `${sunrisePos.toFixed(1)}%`, sunsetPos: `${sunsetPos.toFixed(1)}%`, currentPos: `${currentPos.toFixed(1)}%` }); } /** * Update daylight hours bar and set up interval for current hour updates */ updateDaylightHoursBar() { // Render the bar immediately this.renderDaylightHoursBar(); // Clear existing interval if any if (this.daylightBarUpdateInterval) { clearInterval(this.daylightBarUpdateInterval); } // Update current hour position every minute this.daylightBarUpdateInterval = setInterval(() => { this.renderDaylightHoursBar(); }, 60000); // Update every minute } } // ES module export export { WeatherManager }; // Keep window reference for backward compatibility window.WeatherManager = WeatherManager;