Items 10-15: ES modules, inline style cleanup, template modal, code modernization

- Item 10: Convert to ES modules with import/export, single module entry point
- Item 11: Replace inline styles with CSS classes (background overlay, card
  animations, highlight effect, config modal form elements)
- Item 12: Move ConfigManager modal HTML from JS template literal to
  <template> element in index.html
- Item 13: Replace deprecated url.parse() with new URL() in server.js
  and update route handlers to use searchParams
- Item 14: Replace JSON.parse/stringify deep clone with structuredClone()
- Item 15: Remove dead JSON-fixing regex code from departures.js route

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 14:30:03 +01:00
parent 392a50b535
commit 1fdb3e48c7
12 changed files with 1883 additions and 1780 deletions

View File

@@ -161,5 +161,8 @@ class Clock {
}
}
// Export the Clock class for use in other modules
// ES module export
export { Clock };
// Keep window reference for backward compatibility
window.Clock = Clock;

View File

@@ -83,109 +83,39 @@ class ConfigManager {
* Create the configuration modal
*/
createConfigModal() {
const template = document.getElementById('config-modal-template');
const modalContainer = document.createElement('div');
modalContainer.id = this.options.configModalId;
modalContainer.className = 'config-modal';
modalContainer.style.display = 'none';
modalContainer.innerHTML = `
<div class="config-modal-content">
<div class="config-modal-header">
<h2>Settings</h2>
<span class="config-modal-close">&times;</span>
</div>
<div class="config-tabs">
<button class="config-tab active" data-tab="display">Display</button>
<button class="config-tab" data-tab="appearance">Appearance</button>
<button class="config-tab" data-tab="content">Content</button>
<button class="config-tab" data-tab="options">Options</button>
</div>
<div class="config-modal-body">
<!-- Display Tab -->
<div class="config-tab-content active" id="tab-display">
<div class="config-option">
<label for="orientation-select">Screen Orientation:</label>
<select id="orientation-select">
<option value="normal" ${this.config.orientation === 'normal' ? 'selected' : ''}>Normal (0°)</option>
<option value="vertical" ${this.config.orientation === 'vertical' ? 'selected' : ''}>Vertical (90°)</option>
<option value="upsidedown" ${this.config.orientation === 'upsidedown' ? 'selected' : ''}>Upside Down (180°)</option>
<option value="vertical-reverse" ${this.config.orientation === 'vertical-reverse' ? 'selected' : ''}>Vertical Reverse (270°)</option>
<option value="landscape" ${this.config.orientation === 'landscape' ? 'selected' : ''}>Landscape (2-column)</option>
</select>
</div>
<div class="config-option">
<label for="dark-mode-select">Dark Mode:</label>
<select id="dark-mode-select">
<option value="auto" ${this.config.darkMode === 'auto' ? 'selected' : ''}>Automatic (Sunset/Sunrise)</option>
<option value="on" ${this.config.darkMode === 'on' ? 'selected' : ''}>Always On</option>
<option value="off" ${this.config.darkMode === 'off' ? 'selected' : ''}>Always Off</option>
</select>
<div class="sun-times" id="sun-times">
<small>Sunrise: <span id="sunrise-time">--:--</span> | Sunset: <span id="sunset-time">--:--</span></small>
</div>
</div>
</div>
<!-- Appearance Tab -->
<div class="config-tab-content" id="tab-appearance">
<div class="config-option">
<label for="background-image-url">Background Image:</label>
<input type="text" id="background-image-url" placeholder="Enter image URL" value="${this.config.backgroundImage}">
<div style="display: flex; gap: 10px; margin-top: 5px;">
<button id="test-image-button" style="padding: 5px 10px;">Use Test Image</button>
<label for="local-image-input" style="padding: 5px 10px; background-color: #ddd; border-radius: 4px; cursor: pointer;">
Select Local Image
</label>
<input type="file" id="local-image-input" accept="image/*" style="display: none;">
</div>
<div class="background-preview" id="background-preview">
${this.config.backgroundImage ? `<img src="${this.config.backgroundImage}" alt="Background preview">` : '<div class="no-image">No image selected</div>'}
</div>
</div>
<div class="config-option">
<label for="background-opacity">Background Opacity: <span id="opacity-value">${Math.round(this.config.backgroundOpacity * 100)}%</span></label>
<input type="range" id="background-opacity" min="0" max="1" step="0.05" value="${this.config.backgroundOpacity}">
</div>
</div>
<!-- Content Tab -->
<div class="config-tab-content" id="tab-content">
<div class="config-option">
<label>Transit Sites:</label>
<div style="margin-bottom: 15px;">
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
<input type="text" id="site-search-input" placeholder="Search for transit stop..." style="flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
<button id="search-site-button" style="padding: 8px 15px; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer;">Search</button>
<button id="select-from-map-button" style="padding: 8px 15px; background-color: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer;">Select from Map</button>
</div>
<div id="site-search-results" style="display: none; max-height: 200px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; background: white; margin-top: 5px;"></div>
</div>
<div id="sites-container">
${this.generateSitesHTML()}
</div>
<div style="margin-top: 10px;">
<button id="add-site-button" style="padding: 5px 10px;">Add Site Manually</button>
</div>
</div>
</div>
<!-- Options Tab -->
<div class="config-tab-content" id="tab-options">
<div class="config-option">
<label for="combine-directions">
<input type="checkbox" id="combine-directions" ${this.config.combineSameDirection ? 'checked' : ''}>
Combine departures going in the same direction
</label>
</div>
</div>
</div>
<div class="config-modal-footer">
<button id="config-save-button">Save</button>
<button id="config-cancel-button">Cancel</button>
</div>
</div>
`;
// Clone the template content into the modal
modalContainer.appendChild(template.content.cloneNode(true));
// Set dynamic values from current config
modalContainer.querySelector('#orientation-select').value = this.config.orientation;
modalContainer.querySelector('#dark-mode-select').value = this.config.darkMode;
modalContainer.querySelector('#background-image-url').value = this.config.backgroundImage || '';
modalContainer.querySelector('#background-opacity').value = this.config.backgroundOpacity;
modalContainer.querySelector('#opacity-value').textContent = `${Math.round(this.config.backgroundOpacity * 100)}%`;
modalContainer.querySelector('#combine-directions').checked = this.config.combineSameDirection;
// Populate sites
const sitesContainer = modalContainer.querySelector('#sites-container');
if (sitesContainer) {
sitesContainer.innerHTML = this.generateSitesHTML();
}
// Update background preview
const preview = modalContainer.querySelector('#background-preview');
if (preview && this.config.backgroundImage) {
const img = document.createElement('img');
img.src = this.config.backgroundImage;
img.alt = 'Background preview';
preview.innerHTML = '';
preview.appendChild(img);
}
document.body.appendChild(modalContainer);
// Add tab switching functionality
@@ -549,33 +479,10 @@ class ConfigManager {
if (this.config.backgroundImage && this.config.backgroundImage.trim() !== '') {
const overlay = document.createElement('div');
overlay.id = 'background-overlay';
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.backgroundImage = `url(${this.config.backgroundImage})`;
overlay.style.backgroundSize = 'cover';
overlay.style.backgroundPosition = 'center';
overlay.style.opacity = this.config.backgroundOpacity;
overlay.style.zIndex = '-1';
overlay.style.pointerEvents = 'none';
// Adjust background rotation based on orientation
if (this.config.orientation === 'vertical') {
overlay.style.transform = 'rotate(90deg) scale(2)';
overlay.style.transformOrigin = 'center center';
} else if (this.config.orientation === 'upsidedown') {
overlay.style.transform = 'rotate(180deg) scale(1.5)';
overlay.style.transformOrigin = 'center center';
} else if (this.config.orientation === 'vertical-reverse') {
overlay.style.transform = 'rotate(270deg) scale(2)';
overlay.style.transformOrigin = 'center center';
} else {
overlay.style.transform = 'scale(1.2)';
overlay.style.transformOrigin = 'center center';
}
overlay.className = `orientation-${this.config.orientation}`;
// Insert as the first child of body
document.body.insertBefore(overlay, document.body.firstChild);
}
@@ -614,14 +521,14 @@ class ConfigManager {
return this.config.sites.map((site, index) => `
<div class="site-item" data-index="${index}">
<div style="display: flex; align-items: center; margin-bottom: 5px;">
<div class="config-site-flex">
<input type="checkbox" class="site-enabled" ${site.enabled ? 'checked' : ''}>
<input type="text" class="site-name" value="${site.name}" placeholder="Site Name" style="flex: 1; margin: 0 5px;">
<button class="remove-site-button" style="padding: 2px 5px;">×</button>
<input type="text" class="site-name config-site-name-input" value="${site.name}" placeholder="Site Name">
<button class="remove-site-button config-btn-remove">×</button>
</div>
<div style="display: flex; align-items: center;">
<span style="margin-right: 5px;">ID:</span>
<input type="text" class="site-id" value="${site.id}" placeholder="Site ID" style="width: 100px;">
<div class="config-site-id-row">
<span class="config-site-id-label">ID:</span>
<input type="text" class="site-id config-site-id-input" value="${site.id}" placeholder="Site ID">
</div>
</div>
`).join('');
@@ -644,7 +551,7 @@ class ConfigManager {
try {
resultsContainer.style.display = 'block';
resultsContainer.innerHTML = '<div style="padding: 10px; text-align: center; color: #666;">Searching...</div>';
resultsContainer.textContent = 'Searching...';
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(query)}`);
@@ -656,35 +563,32 @@ class ConfigManager {
const data = await response.json();
if (!data.sites || data.sites.length === 0) {
resultsContainer.innerHTML = '<div style="padding: 10px; text-align: center; color: #666;">No sites found. Try a different search term.</div>';
resultsContainer.textContent = 'No sites found. Try a different search term.';
return;
}
resultsContainer.innerHTML = data.sites.map(site => `
<div class="site-search-result" style="padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; transition: background-color 0.2s;"
data-site-id="${site.id}" data-site-name="${site.name}">
<div style="font-weight: bold; color: #0061a1;">${site.name}</div>
<div style="font-size: 0.85em; color: #666;">ID: ${site.id}</div>
</div>
`).join('');
// Add click handlers to search results
resultsContainer.querySelectorAll('.site-search-result').forEach(result => {
result.addEventListener('click', () => {
const siteId = result.dataset.siteId;
const siteName = result.dataset.siteName;
this.addSiteFromSearch(siteId, siteName);
resultsContainer.innerHTML = '';
data.sites.forEach(site => {
const resultDiv = document.createElement('div');
resultDiv.className = 'site-search-result';
resultDiv.dataset.siteId = site.id;
resultDiv.dataset.siteName = site.name;
const nameDiv = document.createElement('div');
nameDiv.textContent = site.name;
const idDiv = document.createElement('div');
idDiv.textContent = `ID: ${site.id}`;
resultDiv.appendChild(nameDiv);
resultDiv.appendChild(idDiv);
resultDiv.addEventListener('click', () => {
this.addSiteFromSearch(site.id, site.name);
searchInput.value = '';
resultsContainer.style.display = 'none';
});
result.addEventListener('mouseenter', () => {
result.style.backgroundColor = '#f5f5f5';
});
result.addEventListener('mouseleave', () => {
result.style.backgroundColor = 'white';
});
resultsContainer.appendChild(resultDiv);
});
} catch (error) {
@@ -698,7 +602,7 @@ class ConfigManager {
errorMessage = `Server error: ${error.message}`;
}
resultsContainer.innerHTML = `<div style="padding: 10px; text-align: center; color: #d32f2f;">Error: ${errorMessage}</div>`;
resultsContainer.textContent = `Error: ${errorMessage}`;
}
}
@@ -1159,5 +1063,8 @@ class ConfigManager {
}
}
// Export the ConfigManager class for use in other modules
// ES module export
export { ConfigManager };
// Keep window reference for backward compatibility
window.ConfigManager = ConfigManager;

View File

@@ -424,7 +424,7 @@ class DeparturesManager {
this.updateExistingCards(departures);
}
this.currentDepartures = JSON.parse(JSON.stringify(departures));
this.currentDepartures = structuredClone(departures);
}
/**
@@ -444,8 +444,8 @@ class DeparturesManager {
this.updateCardContent(existingCard, departure);
} else {
const newCard = this.createDepartureCard(departure);
newCard.style.opacity = '0';
newCard.classList.add('card-entering');
if (index === 0) {
this.container.prepend(newCard);
} else if (index >= this.container.children.length) {
@@ -453,24 +453,22 @@ class DeparturesManager {
} else {
this.container.insertBefore(newCard, this.container.children[index]);
}
setTimeout(() => {
newCard.style.transition = 'opacity 0.5s ease-in';
newCard.style.opacity = '1';
}, 10);
requestAnimationFrame(() => {
newCard.classList.add('card-visible');
});
}
});
const newDepartureIds = newDepartures.map(d => d.journey.id.toString());
currentCards.forEach(card => {
if (!newDepartureIds.includes(card.dataset.journeyId)) {
card.style.transition = 'opacity 0.5s ease-out';
card.style.opacity = '0';
setTimeout(() => {
if (card.parentNode) {
card.parentNode.removeChild(card);
}
}, 500);
card.classList.add('card-leaving');
card.addEventListener('transitionend', () => {
card.remove();
}, { once: true });
// Fallback removal if transitionend doesn't fire
setTimeout(() => card.remove(), 600);
}
});
}
@@ -505,13 +503,10 @@ class DeparturesManager {
* @param {HTMLElement} element - Element to highlight
*/
highlightElement(element) {
element.style.transition = 'none';
element.style.backgroundColor = 'rgba(255, 255, 0, 0.3)';
setTimeout(() => {
element.style.transition = 'background-color 1.5s ease-out';
element.style.backgroundColor = 'transparent';
}, 10);
element.classList.remove('highlight-flash');
// Force reflow to restart animation
void element.offsetWidth;
element.classList.add('highlight-flash');
}
/**
@@ -632,5 +627,8 @@ class DeparturesManager {
}
}
// Export the class
// ES module export
export { DeparturesManager };
// Keep window reference for backward compatibility
window.DeparturesManager = DeparturesManager;

View File

@@ -8,7 +8,7 @@ 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 || '4d8fb5b93d4af21d66a2948710284366';
const apiKey = options.apiKey || window.OPENWEATHERMAP_API_KEY || '';
this.options = {
latitude: options.latitude || (window.DEFAULT_LOCATION?.latitude) || 59.3293, // Stockholm latitude
@@ -24,6 +24,7 @@ class WeatherManager {
this.sunTimes = null;
this.isDarkMode = false;
this.lastUpdated = null;
this.daylightBarUpdateInterval = null;
// Initialize
this.init();
@@ -34,6 +35,23 @@ class WeatherManager {
*/
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();
@@ -83,17 +101,17 @@ class WeatherManager {
*/
async fetchWeatherData() {
try {
// Fetch current weather
const currentWeatherUrl = `https://api.openweathermap.org/data/2.5/weather?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&appid=${this.options.apiKey}`;
// 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 hourly forecast
const forecastUrl = `https://api.openweathermap.org/data/2.5/forecast?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&appid=${this.options.apiKey}`;
// 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();
@@ -107,7 +125,7 @@ class WeatherManager {
this.lastUpdated = new Date();
// Extract sunrise and sunset times from the API response
this.updateSunTimesFromApi(currentWeatherData);
await this.updateSunTimesFromApi(currentWeatherData);
// Update the UI with the new data
this.updateWeatherUI();
@@ -158,7 +176,7 @@ class WeatherManager {
* Process forecast data from API response
*/
processForecast(data) {
// Get the next 7 forecasts (covering about 24 hours)
// 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 {
@@ -375,6 +393,11 @@ class WeatherManager {
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);
}
@@ -383,120 +406,86 @@ class WeatherManager {
/**
* Update sunrise and sunset times from API data
*/
updateSunTimesFromApi(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');
this.updateSunTimesFromCalculation();
await this.updateSunTimesFromCalculation();
return;
}
try {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// Create Date objects from Unix timestamps
// Create Date objects from Unix timestamps for today
const sunrise = new Date(data.sys.sunrise * 1000);
const sunset = new Date(data.sys.sunset * 1000);
// Use calculated times for tomorrow
const tomorrowTimes = this.calculateSunTimes(tomorrow);
// 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);
this.updateSunTimesFromCalculation();
await this.updateSunTimesFromCalculation();
}
}
/**
* Update sunrise and sunset times using calculation
* Update sunrise and sunset times using sunrise-sunset.org API
* Falls back to hardcoded defaults if the API is unreachable
*/
async updateSunTimesFromCalculation() {
try {
// Calculate sun times based on date and location
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const [todayData, tomorrowData] = await Promise.all([
this.fetchSunTimes('today'),
this.fetchSunTimes('tomorrow')
]);
this.sunTimes = {
today: this.calculateSunTimes(today),
tomorrow: this.calculateSunTimes(tomorrow)
today: todayData,
tomorrow: tomorrowData
};
console.log('Sun times updated from calculation:', this.sunTimes);
console.log('Sun times updated from sunrise-sunset.org:', this.sunTimes);
return this.sunTimes;
} catch (error) {
console.error('Error updating sun times from calculation:', error);
// Fallback to default times if calculation fails
console.error('Error fetching sun times from API, using defaults:', error);
const defaultSunrise = new Date();
defaultSunrise.setHours(6, 45, 0, 0);
defaultSunrise.setHours(7, 0, 0, 0);
const defaultSunset = new Date();
defaultSunset.setHours(17, 32, 0, 0);
defaultSunset.setHours(16, 0, 0, 0);
this.sunTimes = {
today: {
sunrise: defaultSunrise,
sunset: defaultSunset
},
tomorrow: {
sunrise: defaultSunrise,
sunset: defaultSunset
}
today: { sunrise: defaultSunrise, sunset: defaultSunset },
tomorrow: { sunrise: defaultSunrise, sunset: defaultSunset }
};
return this.sunTimes;
}
}
/**
* Calculate sunrise and sunset times for a given date
* Uses a simplified algorithm
* Fetch sunrise/sunset times from sunrise-sunset.org API
* @param {string} date - 'today', 'tomorrow', or YYYY-MM-DD
* @returns {Object} { sunrise: Date, sunset: Date }
*/
calculateSunTimes(date) {
// This is a simplified calculation
// For more accuracy, you would use a proper astronomical calculation
// Get day of year
const start = new Date(date.getFullYear(), 0, 0);
const diff = date - start;
const oneDay = 1000 * 60 * 60 * 24;
const dayOfYear = Math.floor(diff / oneDay);
// Calculate sunrise and sunset times based on latitude and day of year
// This is a very simplified model
const latitude = this.options.latitude;
// Base sunrise and sunset times (in hours)
let baseSunrise = 6; // 6 AM
let baseSunset = 18; // 6 PM
// Adjust for latitude and season
// Northern hemisphere seasonal adjustment
const seasonalAdjustment = Math.sin((dayOfYear - 81) / 365 * 2 * Math.PI) * 3;
// Latitude adjustment (higher latitudes have more extreme day lengths)
const latitudeAdjustment = Math.abs(latitude) / 90 * 2;
// Apply adjustments
baseSunrise += seasonalAdjustment * latitudeAdjustment * -1;
baseSunset += seasonalAdjustment * latitudeAdjustment;
// Create Date objects
const sunrise = new Date(date);
sunrise.setHours(Math.floor(baseSunrise), Math.round((baseSunrise % 1) * 60), 0, 0);
const sunset = new Date(date);
sunset.setHours(Math.floor(baseSunset), Math.round((baseSunset % 1) * 60), 0, 0);
return { sunrise, sunset };
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)
};
}
/**
@@ -588,7 +577,136 @@ class WeatherManager {
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
}
}
// Export the WeatherManager class for use in other modules
// ES module export
export { WeatherManager };
// Keep window reference for backward compatibility
window.WeatherManager = WeatherManager;