Refactor: Complete codebase reorganization and modernization
- Split server.js routes into modular files (server/routes/) - departures.js: Departure data endpoints - sites.js: Site search and nearby sites - config.js: Configuration endpoints - Reorganized file structure following Node.js best practices: - Moved sites-config.json to config/sites.json - Moved API_RESPONSE_DOCUMENTATION.md to docs/ - Moved raspberry-pi-setup.sh to scripts/ - Archived legacy files to archive/ directory - Updated all code references to new file locations - Added archive/ to .gitignore to exclude legacy files from repo - Updated README.md with new structure and organization - All functionality tested and working correctly Version: 1.2.0
This commit is contained in:
165
public/js/components/Clock.js
Normal file
165
public/js/components/Clock.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* clock.js - A modular clock component for displaying time and date
|
||||
* Specifically configured for Stockholm, Sweden timezone
|
||||
*/
|
||||
|
||||
class Clock {
|
||||
constructor(options = {}) {
|
||||
// Default options
|
||||
this.options = {
|
||||
elementId: 'clock',
|
||||
timeFormat: 'HH:MM:SS',
|
||||
dateFormat: 'WEEKDAY, MONTH DAY, YEAR',
|
||||
timezone: 'Europe/Stockholm',
|
||||
updateInterval: 1000, // Update every second
|
||||
enableTimeSync: false, // Disable time sync by default to avoid CORS issues with local files
|
||||
...options
|
||||
};
|
||||
|
||||
this.element = document.getElementById(this.options.elementId);
|
||||
if (!this.element) {
|
||||
console.error(`Clock element with ID "${this.options.elementId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create DOM structure
|
||||
this.createClockElements();
|
||||
|
||||
// Start the clock
|
||||
this.start();
|
||||
|
||||
// Sync with time server once a day (if enabled)
|
||||
if (this.options.enableTimeSync) {
|
||||
this.setupTimeSync();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the DOM elements for the clock
|
||||
*/
|
||||
createClockElements() {
|
||||
// Create container with appropriate styling
|
||||
this.element.classList.add('clock-container');
|
||||
|
||||
// Create time element
|
||||
this.timeElement = document.createElement('div');
|
||||
this.timeElement.classList.add('clock-time');
|
||||
this.element.appendChild(this.timeElement);
|
||||
|
||||
// Create a separate date element
|
||||
this.dateElement = document.createElement('div');
|
||||
this.dateElement.classList.add('clock-date');
|
||||
this.element.appendChild(this.dateElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the clock with the specified update interval
|
||||
*/
|
||||
start() {
|
||||
// Update immediately
|
||||
this.update();
|
||||
|
||||
// Set interval for updates
|
||||
this.intervalId = setInterval(() => this.update(), this.options.updateInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the clock
|
||||
*/
|
||||
stop() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the clock display
|
||||
*/
|
||||
update() {
|
||||
const now = new Date();
|
||||
|
||||
// Format and display the time
|
||||
this.timeElement.innerHTML = this.formatTime(now);
|
||||
|
||||
// Format and display the date (with a separator)
|
||||
this.dateElement.textContent = " • " + this.formatDate(now);
|
||||
|
||||
// Make sure the date element is visible and inline
|
||||
this.dateElement.style.display = 'inline-block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the time according to the specified format
|
||||
* @param {Date} date - The date object to format
|
||||
* @returns {string} - The formatted time string
|
||||
*/
|
||||
formatTime(date) {
|
||||
const options = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: this.options.timezone
|
||||
};
|
||||
|
||||
return date.toLocaleTimeString('sv-SE', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the date according to the specified format
|
||||
* @param {Date} date - The date object to format
|
||||
* @returns {string} - The formatted date string
|
||||
*/
|
||||
formatDate(date) {
|
||||
const options = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: this.options.timezone
|
||||
};
|
||||
|
||||
return date.toLocaleDateString('sv-SE', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up time synchronization with a time server
|
||||
* This will sync the time once a day to ensure accuracy
|
||||
*/
|
||||
setupTimeSync() {
|
||||
// Function to sync time
|
||||
const syncTime = async () => {
|
||||
try {
|
||||
// Check if we're running from a local file (which would cause CORS issues)
|
||||
if (window.location.protocol === 'file:') {
|
||||
console.log('Running from local file, skipping time sync to avoid CORS issues');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the WorldTimeAPI to get the current time for Stockholm
|
||||
const response = await fetch('https://worldtimeapi.org/api/timezone/Europe/Stockholm');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Time synced with WorldTimeAPI:', data);
|
||||
|
||||
// The API already returns the time in the correct timezone
|
||||
// We just log it for verification purposes
|
||||
} catch (error) {
|
||||
console.log('Time sync skipped:', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Sync immediately on load
|
||||
syncTime();
|
||||
|
||||
// Then sync once a day (86400000 ms = 24 hours)
|
||||
setInterval(syncTime, 86400000);
|
||||
}
|
||||
}
|
||||
|
||||
// Export the Clock class for use in other modules
|
||||
window.Clock = Clock;
|
||||
1163
public/js/components/ConfigManager.js
Normal file
1163
public/js/components/ConfigManager.js
Normal file
File diff suppressed because it is too large
Load Diff
636
public/js/components/DeparturesManager.js
Normal file
636
public/js/components/DeparturesManager.js
Normal file
@@ -0,0 +1,636 @@
|
||||
/**
|
||||
* DeparturesManager - Manages fetching and displaying transit departures
|
||||
* Refactored from global functions to a class-based architecture
|
||||
*/
|
||||
|
||||
class DeparturesManager {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
apiUrl: options.apiUrl || (window.Constants ? `${window.Constants.API_BASE_URL}${window.Constants.ENDPOINTS.DEPARTURES}` : 'http://localhost:3002/api/departures'),
|
||||
refreshInterval: options.refreshInterval || (window.Constants ? window.Constants.REFRESH.DEPARTURES : 5000),
|
||||
containerId: options.containerId || 'departures',
|
||||
statusId: options.statusId || 'status',
|
||||
lastUpdatedId: options.lastUpdatedId || 'last-updated',
|
||||
...options
|
||||
};
|
||||
|
||||
// DOM element references
|
||||
this.container = null;
|
||||
this.statusElement = null;
|
||||
this.lastUpdatedElement = null;
|
||||
|
||||
// State
|
||||
this.currentDepartures = [];
|
||||
this.refreshTimer = null;
|
||||
|
||||
// Initialize
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the departures manager
|
||||
*/
|
||||
init() {
|
||||
// Get DOM elements
|
||||
this.container = document.getElementById(this.options.containerId);
|
||||
this.statusElement = document.getElementById(this.options.statusId);
|
||||
this.lastUpdatedElement = document.getElementById(this.options.lastUpdatedId);
|
||||
|
||||
if (!this.container) {
|
||||
if (window.logger) {
|
||||
window.logger.error(`Departures container with ID "${this.options.containerId}" not found`);
|
||||
} else {
|
||||
console.error(`Departures container with ID "${this.options.containerId}" not found`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial fetch and setup
|
||||
this.fetchDepartures();
|
||||
this.setupAutoRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate minutes until arrival using expected time (or scheduled if expected not available)
|
||||
* @param {Object} departure - Departure object
|
||||
* @returns {number} Minutes until arrival
|
||||
*/
|
||||
calculateMinutesUntilArrival(departure) {
|
||||
const now = new Date();
|
||||
const arrivalTime = departure.expected ? new Date(departure.expected) : new Date(departure.scheduled);
|
||||
return Math.round((arrivalTime - now) / (1000 * 60));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the time to display (expected if available, otherwise scheduled)
|
||||
* @param {Object} departure - Departure object
|
||||
* @returns {string} ISO time string
|
||||
*/
|
||||
getDepartureTime(departure) {
|
||||
return departure.expected || departure.scheduled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transport icon SVG based on transport mode
|
||||
* @param {string} transportMode - Transport mode (bus, metro, train, tram, ship)
|
||||
* @param {Object} line - Optional line object for special cases
|
||||
* @returns {string} SVG icon HTML
|
||||
*/
|
||||
static getTransportIcon(transportMode, line = null) {
|
||||
const mode = transportMode ? transportMode.toLowerCase() : 'bus';
|
||||
|
||||
// Special case for line 7 - it's a tram
|
||||
if (line && line.designation === '7') {
|
||||
return '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M13 5l.75-1.5H17V2H7v1.5h4.75L11 5c-3.13.09-6 .73-6 3.5V17c0 1.5 1.11 2.73 2.55 2.95L6 21.5v.5h2l2-2h4l2 2h2v-.5l-1.55-1.55C17.89 19.73 19 18.5 19 17V8.5c0-2.77-2.87-3.41-6-3.5zm-1 13.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm5-4.5H7V9h10v5z"/></svg>';
|
||||
}
|
||||
|
||||
const icons = {
|
||||
bus: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4 16c0 .88.39 1.67 1 2.22V20c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h8v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1.78c.61-.55 1-1.34 1-2.22V6c0-3.5-3.58-4-8-4s-8 .5-8 4v10zm3.5 1c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm9 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm1.5-6H6V6h12v5z"/></svg>',
|
||||
metro: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20v1h12v-1l-1.5-1c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-4-4-8-4zm0 2c3.51 0 4.96.48 5.57 1H6.43c.61-.52 2.06-1 5.57-1zM6 7h5v3H6V7zm12 0v3h-5V7h5zm-6 5v3H6v-3h6zm1 0h5v3h-5v-3z"/></svg>',
|
||||
train: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20v1h12v-1l-1.5-1c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-4-4-8-4zm0 2c3.51 0 4.96.48 5.57 1H6.43c.61-.52 2.06-1 5.57-1zM6 7h5v3H6V7zm12 0v3h-5V7h5zm-6 5v3H6v-3h6zm1 0h5v3h-5v-3z"/></svg>',
|
||||
tram: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M13 5l.75-1.5H17V2H7v1.5h4.75L11 5c-3.13.09-6 .73-6 3.5V17c0 1.5 1.11 2.73 2.55 2.95L6 21.5v.5h2l2-2h4l2 2h2v-.5l-1.55-1.55C17.89 19.73 19 18.5 19 17V8.5c0-2.77-2.87-3.41-6-3.5zm-1 13.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm5-4.5H7V9h10v5z"/></svg>',
|
||||
ship: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M20 21c-1.39 0-2.78-.47-4-1.32-2.44 1.71-5.56 1.71-8 0C6.78 20.53 5.39 21 4 21H2v2h2c1.38 0 2.74-.35 4-.99 2.52 1.29 5.48 1.29 8 0 1.26.65 2.62.99 4 .99h2v-2h-2zM3.95 19H4c1.6 0 3.02-.88 4-2 .98 1.12 2.4 2 4 2s3.02-.88 4-2c.98 1.12 2.4 2 4 2h.05l1.89-6.68c.08-.26.06-.54-.06-.78s-.34-.42-.6-.5L20 10.62V6c0-1.1-.9-2-2-2h-3V1H9v3H6c-1.1 0-2 .9-2 2v4.62l-1.29.42c-.26.08-.48.26-.6.5s-.15.52-.06.78L3.95 19zM6 6h12v3.97L12 8 6 9.97V6z"/></svg>'
|
||||
};
|
||||
|
||||
return icons[mode] || icons.bus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date and time
|
||||
* @param {string} dateTimeString - ISO date string
|
||||
* @returns {string} Formatted time string
|
||||
*/
|
||||
static formatDateTime(dateTimeString) {
|
||||
const date = new Date(dateTimeString);
|
||||
const locale = window.Constants?.DATE_FORMAT?.LOCALE || 'sv-SE';
|
||||
return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "in 5 minutes")
|
||||
* @param {string} dateTimeString - ISO date string
|
||||
* @returns {string} Formatted relative time string
|
||||
*/
|
||||
static formatRelativeTime(dateTimeString) {
|
||||
const departureTime = new Date(dateTimeString);
|
||||
const now = new Date();
|
||||
const diffMinutes = Math.round((departureTime - now) / (1000 * 60));
|
||||
|
||||
if (diffMinutes <= 0) {
|
||||
return 'Nu';
|
||||
} else if (diffMinutes === 1) {
|
||||
return 'In 1 minute';
|
||||
} else if (diffMinutes < 60) {
|
||||
return `In ${diffMinutes} minutes`;
|
||||
} else {
|
||||
const hours = Math.floor(diffMinutes / 60);
|
||||
const minutes = diffMinutes % 60;
|
||||
if (minutes === 0) {
|
||||
return `In ${hours} hour${hours > 1 ? 's' : ''}`;
|
||||
} else {
|
||||
return `In ${hours} hour${hours > 1 ? 's' : ''} and ${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration from ConfigManager or return defaults
|
||||
* @returns {Object} Configuration object
|
||||
*/
|
||||
getConfig() {
|
||||
const defaultConfig = {
|
||||
combineSameDirection: true,
|
||||
sites: [
|
||||
{
|
||||
id: window.Constants?.DEFAULT_SITE?.ID || '1411',
|
||||
name: window.Constants?.DEFAULT_SITE?.NAME || 'Ambassaderna',
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (window.configManager && window.configManager.config) {
|
||||
return {
|
||||
combineSameDirection: window.configManager.config.combineSameDirection !== undefined ?
|
||||
window.configManager.config.combineSameDirection : defaultConfig.combineSameDirection,
|
||||
sites: window.configManager.config.sites || defaultConfig.sites
|
||||
};
|
||||
}
|
||||
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group departures by line number
|
||||
* @param {Array} departures - Array of departure objects
|
||||
* @returns {Array} Array of grouped departure objects
|
||||
*/
|
||||
groupDeparturesByLineNumber(departures) {
|
||||
const groups = {};
|
||||
|
||||
departures.forEach(departure => {
|
||||
const lineNumber = departure.line.designation;
|
||||
|
||||
if (!groups[lineNumber]) {
|
||||
groups[lineNumber] = {
|
||||
line: departure.line,
|
||||
directions: {}
|
||||
};
|
||||
}
|
||||
|
||||
const departureDirection = departure.direction_code !== undefined ? departure.direction_code :
|
||||
departure.directionCode !== undefined ? departure.directionCode :
|
||||
departure.direction !== undefined ? departure.direction :
|
||||
1;
|
||||
|
||||
const directionKey = `${departureDirection}-${departure.destination}`;
|
||||
|
||||
if (!groups[lineNumber].directions[directionKey]) {
|
||||
groups[lineNumber].directions[directionKey] = {
|
||||
direction: departureDirection,
|
||||
destination: departure.destination,
|
||||
departures: []
|
||||
};
|
||||
}
|
||||
|
||||
groups[lineNumber].directions[directionKey].departures.push(departure);
|
||||
});
|
||||
|
||||
return Object.entries(groups).map(([lineNumber, data]) => {
|
||||
const directionsArray = Object.values(data.directions);
|
||||
directionsArray.sort((a, b) => {
|
||||
const dirA = a.direction || 1;
|
||||
const dirB = b.direction || 1;
|
||||
return dirA - dirB;
|
||||
});
|
||||
|
||||
return {
|
||||
lineNumber: lineNumber,
|
||||
line: data.line,
|
||||
directions: directionsArray
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine countdown text and class based on minutes until arrival
|
||||
* @param {Object} departure - Departure object
|
||||
* @returns {Object} Object with countdownText and countdownClass
|
||||
*/
|
||||
getCountdownInfo(departure) {
|
||||
const displayTime = departure.display;
|
||||
const minutesUntil = this.calculateMinutesUntilArrival(departure);
|
||||
let countdownText = displayTime;
|
||||
let countdownClass = '';
|
||||
|
||||
const urgentThreshold = window.Constants?.TIME_THRESHOLDS?.URGENT || 5;
|
||||
|
||||
if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
|
||||
countdownText = 'Nu';
|
||||
countdownClass = 'now';
|
||||
} else if (minutesUntil < urgentThreshold) {
|
||||
const minMatch = displayTime.match(/(\d+)\s*min/i);
|
||||
if (minMatch) {
|
||||
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
|
||||
} else {
|
||||
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
|
||||
}
|
||||
countdownClass = 'urgent';
|
||||
} else {
|
||||
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||
if (isTimeOnly) {
|
||||
countdownText = `${minutesUntil} min`;
|
||||
} else {
|
||||
countdownText = displayTime;
|
||||
}
|
||||
}
|
||||
|
||||
return { countdownText, countdownClass };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a departure card element (legacy format)
|
||||
* @param {Object} departure - Departure object
|
||||
* @returns {HTMLElement} Departure card element
|
||||
*/
|
||||
createDepartureCard(departure) {
|
||||
const departureCard = document.createElement('div');
|
||||
departureCard.dataset.journeyId = departure.journey.id;
|
||||
|
||||
const displayTime = departure.display;
|
||||
const departureTime = this.getDepartureTime(departure);
|
||||
const timeDisplay = DeparturesManager.formatDateTime(departureTime);
|
||||
|
||||
const departureTimeDate = new Date(departureTime);
|
||||
const now = new Date();
|
||||
const diffMinutes = Math.round((departureTimeDate - now) / (1000 * 60));
|
||||
const withinHourThreshold = window.Constants?.TIME_THRESHOLDS?.WITHIN_HOUR || 60;
|
||||
const isWithinNextHour = diffMinutes <= withinHourThreshold;
|
||||
|
||||
departureCard.className = isWithinNextHour ? 'departure-card condensed' : 'departure-card';
|
||||
|
||||
const { countdownText, countdownClass } = this.getCountdownInfo(departure);
|
||||
const transportIcon = DeparturesManager.getTransportIcon(departure.line?.transportMode, departure.line);
|
||||
|
||||
departureCard.innerHTML = `
|
||||
<div class="departure-header">
|
||||
<span class="line-number">
|
||||
${transportIcon}
|
||||
${departure.line.designation}
|
||||
<span class="line-destination">${departure.destination}</span>
|
||||
</span>
|
||||
<span class="time">
|
||||
<span class="arrival-time">${timeDisplay}</span>
|
||||
<span class="countdown ${countdownClass}">(${countdownText})</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return departureCard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display grouped departures by line (new card-based layout)
|
||||
* @param {Array} groups - Array of grouped departure objects
|
||||
* @param {HTMLElement} container - Container element
|
||||
*/
|
||||
displayGroupedDeparturesByLine(groups, container) {
|
||||
groups.forEach(group => {
|
||||
const groupCard = document.createElement('div');
|
||||
groupCard.className = 'departure-card line-card';
|
||||
|
||||
const apiTransportMode = group.line?.transportMode || '';
|
||||
const transportMode = apiTransportMode.toLowerCase();
|
||||
|
||||
const lineNumberBox = document.createElement('div');
|
||||
lineNumberBox.className = `line-number-box ${transportMode}`;
|
||||
|
||||
const transportIcon = DeparturesManager.getTransportIcon(group.line?.transportMode, group.line);
|
||||
|
||||
lineNumberBox.innerHTML = `
|
||||
<div class="transport-mode-icon">${transportIcon}</div>
|
||||
<div class="line-number-large">${group.lineNumber}</div>
|
||||
`;
|
||||
groupCard.appendChild(lineNumberBox);
|
||||
|
||||
const directionsWrapper = document.createElement('div');
|
||||
directionsWrapper.className = 'directions-wrapper';
|
||||
|
||||
const maxDirections = 2;
|
||||
group.directions.slice(0, maxDirections).forEach(direction => {
|
||||
direction.departures.sort((a, b) => {
|
||||
const timeA = this.getDepartureTime(a);
|
||||
const timeB = this.getDepartureTime(b);
|
||||
return new Date(timeA) - new Date(timeB);
|
||||
});
|
||||
|
||||
const directionRow = document.createElement('div');
|
||||
directionRow.className = 'direction-row';
|
||||
|
||||
const directionInfo = document.createElement('div');
|
||||
directionInfo.className = 'direction-info';
|
||||
|
||||
const firstDep = direction.departures[0];
|
||||
if (!firstDep) return;
|
||||
|
||||
const directionCode = firstDep.direction_code !== undefined ? firstDep.direction_code :
|
||||
firstDep.directionCode !== undefined ? firstDep.directionCode :
|
||||
null;
|
||||
|
||||
const isRight = directionCode === 2;
|
||||
|
||||
if (directionCode === null || directionCode === undefined) {
|
||||
if (window.logger) {
|
||||
window.logger.warn('No direction_code found for:', direction.destination, firstDep);
|
||||
} else {
|
||||
console.warn('No direction_code found for:', direction.destination, firstDep);
|
||||
}
|
||||
}
|
||||
|
||||
const arrowBox = document.createElement('div');
|
||||
arrowBox.className = `direction-arrow-box ${isRight ? 'right' : 'left'}`;
|
||||
arrowBox.textContent = isRight ? '→' : '←';
|
||||
|
||||
const destinationSpan = document.createElement('span');
|
||||
destinationSpan.className = 'direction-destination';
|
||||
destinationSpan.textContent = direction.destination;
|
||||
|
||||
directionInfo.appendChild(arrowBox);
|
||||
directionInfo.appendChild(destinationSpan);
|
||||
directionRow.appendChild(directionInfo);
|
||||
|
||||
const timesContainer = document.createElement('div');
|
||||
timesContainer.className = 'times-container';
|
||||
|
||||
const firstDeparture = direction.departures[0];
|
||||
const secondDeparture = direction.departures[1];
|
||||
|
||||
if (firstDeparture) {
|
||||
const displayTime = firstDeparture.display;
|
||||
const departureTime = this.getDepartureTime(firstDeparture);
|
||||
const timeDisplay = DeparturesManager.formatDateTime(departureTime);
|
||||
|
||||
const { countdownText, countdownClass } = this.getCountdownInfo(firstDeparture);
|
||||
|
||||
const timeDisplayElement = document.createElement('div');
|
||||
timeDisplayElement.className = 'time-display';
|
||||
|
||||
const countdownSpan = document.createElement('span');
|
||||
countdownSpan.className = `countdown-large ${countdownClass}`;
|
||||
countdownSpan.textContent = countdownText;
|
||||
|
||||
timeDisplayElement.appendChild(countdownSpan);
|
||||
|
||||
const timeRangeSpan = document.createElement('span');
|
||||
timeRangeSpan.className = 'time-range';
|
||||
if (secondDeparture) {
|
||||
const secondTime = DeparturesManager.formatDateTime(this.getDepartureTime(secondDeparture));
|
||||
timeRangeSpan.textContent = `${timeDisplay} - ${secondTime}`;
|
||||
} else {
|
||||
timeRangeSpan.textContent = timeDisplay;
|
||||
}
|
||||
timeDisplayElement.appendChild(timeRangeSpan);
|
||||
|
||||
timesContainer.appendChild(timeDisplayElement);
|
||||
}
|
||||
|
||||
directionRow.appendChild(timesContainer);
|
||||
directionsWrapper.appendChild(directionRow);
|
||||
});
|
||||
|
||||
groupCard.appendChild(directionsWrapper);
|
||||
container.appendChild(groupCard);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display departures in the UI
|
||||
* @param {Array} departures - Array of departure objects
|
||||
*/
|
||||
displayDepartures(departures) {
|
||||
if (!departures || departures.length === 0) {
|
||||
this.container.innerHTML = '<div class="error">No departures found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentDepartures.length === 0) {
|
||||
this.container.innerHTML = '';
|
||||
|
||||
departures.forEach(departure => {
|
||||
const departureCard = this.createDepartureCard(departure);
|
||||
this.container.appendChild(departureCard);
|
||||
});
|
||||
} else {
|
||||
this.updateExistingCards(departures);
|
||||
}
|
||||
|
||||
this.currentDepartures = JSON.parse(JSON.stringify(departures));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing cards or add new ones
|
||||
* @param {Array} newDepartures - Array of new departure objects
|
||||
*/
|
||||
updateExistingCards(newDepartures) {
|
||||
const currentCards = this.container.querySelectorAll('.departure-card');
|
||||
const currentCardIds = Array.from(currentCards).map(card => card.dataset.journeyId);
|
||||
|
||||
newDepartures.forEach((departure, index) => {
|
||||
const journeyId = departure.journey.id;
|
||||
const existingCardIndex = currentCardIds.indexOf(journeyId.toString());
|
||||
|
||||
if (existingCardIndex !== -1) {
|
||||
const existingCard = currentCards[existingCardIndex];
|
||||
this.updateCardContent(existingCard, departure);
|
||||
} else {
|
||||
const newCard = this.createDepartureCard(departure);
|
||||
newCard.style.opacity = '0';
|
||||
|
||||
if (index === 0) {
|
||||
this.container.prepend(newCard);
|
||||
} else if (index >= this.container.children.length) {
|
||||
this.container.appendChild(newCard);
|
||||
} else {
|
||||
this.container.insertBefore(newCard, this.container.children[index]);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
newCard.style.transition = 'opacity 0.5s ease-in';
|
||||
newCard.style.opacity = '1';
|
||||
}, 10);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update card content
|
||||
* @param {HTMLElement} card - Card element
|
||||
* @param {Object} departure - Departure object
|
||||
*/
|
||||
updateCardContent(card, departure) {
|
||||
const { countdownText, countdownClass } = this.getCountdownInfo(departure);
|
||||
const countdownElement = card.querySelector('.countdown');
|
||||
|
||||
if (countdownElement) {
|
||||
countdownElement.classList.remove('now', 'urgent');
|
||||
|
||||
if (countdownClass === 'now') {
|
||||
countdownElement.classList.add('now');
|
||||
} else if (countdownClass === 'urgent') {
|
||||
countdownElement.classList.add('urgent');
|
||||
}
|
||||
|
||||
if (countdownElement.textContent !== `(${countdownText})`) {
|
||||
countdownElement.textContent = `(${countdownText})`;
|
||||
this.highlightElement(countdownElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add highlight effect to element
|
||||
* @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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display multiple sites
|
||||
* @param {Array} sites - Array of site objects
|
||||
*/
|
||||
displayMultipleSites(sites) {
|
||||
const config = this.getConfig();
|
||||
const enabledSites = config.sites.filter(site => site.enabled);
|
||||
|
||||
this.container.innerHTML = '';
|
||||
|
||||
sites.forEach(site => {
|
||||
const siteConfig = enabledSites.find(s => s.id === site.siteId);
|
||||
if (!siteConfig) return;
|
||||
|
||||
const siteContainer = document.createElement('div');
|
||||
siteContainer.className = 'site-container';
|
||||
|
||||
const siteHeader = document.createElement('div');
|
||||
siteHeader.className = 'site-header';
|
||||
siteHeader.innerHTML = `<span class="site-name">${site.siteName || siteConfig.name}</span>`;
|
||||
siteContainer.appendChild(siteHeader);
|
||||
|
||||
if (site.data && site.data.departures) {
|
||||
const lineGroups = this.groupDeparturesByLineNumber(site.data.departures);
|
||||
this.displayGroupedDeparturesByLine(lineGroups, siteContainer);
|
||||
} else if (site.error) {
|
||||
const errorElement = document.createElement('div');
|
||||
errorElement.className = 'error';
|
||||
errorElement.textContent = `Error loading departures for ${site.siteName}: ${site.error}`;
|
||||
siteContainer.appendChild(errorElement);
|
||||
}
|
||||
|
||||
this.container.appendChild(siteContainer);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch departures from API
|
||||
*/
|
||||
async fetchDepartures() {
|
||||
try {
|
||||
const response = await fetch(this.options.apiUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.sites && Array.isArray(data.sites)) {
|
||||
this.displayMultipleSites(data.sites);
|
||||
|
||||
if (this.lastUpdatedElement) {
|
||||
const now = new Date();
|
||||
const locale = window.Constants?.DATE_FORMAT?.LOCALE || 'sv-SE';
|
||||
this.lastUpdatedElement.textContent = `Last updated: ${now.toLocaleTimeString(locale)}`;
|
||||
}
|
||||
} else if (data.departures) {
|
||||
this.displayDepartures(data.departures);
|
||||
|
||||
if (this.lastUpdatedElement) {
|
||||
const now = new Date();
|
||||
const locale = window.Constants?.DATE_FORMAT?.LOCALE || 'sv-SE';
|
||||
this.lastUpdatedElement.textContent = `Last updated: ${now.toLocaleTimeString(locale)}`;
|
||||
}
|
||||
} else if (data.error) {
|
||||
throw new Error(data.error);
|
||||
} else {
|
||||
throw new Error('Invalid response format from server');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (window.logger) {
|
||||
window.logger.error('Error fetching departures:', error);
|
||||
} else {
|
||||
console.error('Error fetching departures:', error);
|
||||
}
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="error">
|
||||
<p>Failed to load departures. Please try again later.</p>
|
||||
<p>Error: ${error.message}</p>
|
||||
<p>Make sure the Node.js server is running: <code>node server.js</code></p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up auto-refresh timer
|
||||
*/
|
||||
setupAutoRefresh() {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
}
|
||||
|
||||
this.refreshTimer = setInterval(() => this.fetchDepartures(), this.options.refreshInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-refresh
|
||||
*/
|
||||
stop() {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart auto-refresh with new interval
|
||||
* @param {number} interval - New interval in milliseconds
|
||||
*/
|
||||
setRefreshInterval(interval) {
|
||||
this.options.refreshInterval = interval;
|
||||
this.setupAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
// Export the class
|
||||
window.DeparturesManager = DeparturesManager;
|
||||
594
public/js/components/WeatherManager.js
Normal file
594
public/js/components/WeatherManager.js
Normal file
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* 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 || '4d8fb5b93d4af21d66a2948710284366';
|
||||
|
||||
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;
|
||||
|
||||
// Initialize
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the weather manager
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
// 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
|
||||
const currentWeatherUrl = `https://api.openweathermap.org/data/2.5/weather?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&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}`;
|
||||
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
|
||||
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 forecasts (covering about 24 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}@2x.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if icon represents sun (even behind clouds)
|
||||
*/
|
||||
isSunIcon(iconCode, condition) {
|
||||
// Icon codes: 01d, 01n = clear, 02d, 02n = few clouds, 03d, 03n = scattered, 04d, 04n = broken clouds
|
||||
const sunIconCodes = ['01d', '01n', '02d', '02n', '03d', '03n', '04d', '04n'];
|
||||
return sunIconCodes.includes(iconCode) ||
|
||||
condition.includes('Clear') ||
|
||||
condition.includes('Clouds');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if icon is clear sun (no clouds)
|
||||
*/
|
||||
isClearSun(iconCode, condition) {
|
||||
const clearIconCodes = ['01d', '01n'];
|
||||
return clearIconCodes.includes(iconCode) || condition === 'Clear';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if icon is sun behind clouds
|
||||
*/
|
||||
isSunBehindClouds(iconCode, condition) {
|
||||
const cloudIconCodes = ['02d', '02n', '03d', '03n', '04d', '04n'];
|
||||
return cloudIconCodes.includes(iconCode) || (condition.includes('Clouds') && !condition.includes('Clear'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if icon represents snow
|
||||
*/
|
||||
isSnowIcon(iconCode, condition) {
|
||||
// Icon code: 13d, 13n = snow
|
||||
const snowIconCodes = ['13d', '13n'];
|
||||
return snowIconCodes.includes(iconCode) || condition.includes('Snow');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default weather data for fallback
|
||||
*/
|
||||
createDefaultWeatherData() {
|
||||
return {
|
||||
temperature: 7.1,
|
||||
condition: 'Clear',
|
||||
description: 'clear sky',
|
||||
icon: 'https://openweathermap.org/img/wn/01d@2x.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@2x.png' : 'https://openweathermap.org/img/wn/02n@2x.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;
|
||||
// Add classes and data attributes for color filtering
|
||||
iconElement.setAttribute('data-condition', this.weatherData.condition);
|
||||
iconElement.classList.remove('weather-sun', 'weather-snow', 'weather-clear-sun', 'weather-clouds-sun');
|
||||
if (this.isSnowIcon(this.weatherData.iconCode, this.weatherData.condition)) {
|
||||
iconElement.classList.add('weather-snow');
|
||||
} else if (this.isClearSun(this.weatherData.iconCode, this.weatherData.condition)) {
|
||||
iconElement.classList.add('weather-sun', 'weather-clear-sun');
|
||||
} else if (this.isSunBehindClouds(this.weatherData.iconCode, this.weatherData.condition)) {
|
||||
iconElement.classList.add('weather-sun', 'weather-clouds-sun');
|
||||
} else if (this.isSunIcon(this.weatherData.iconCode, this.weatherData.condition)) {
|
||||
iconElement.classList.add('weather-sun');
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (this.isSnowIcon(this.weatherData.iconCode, this.weatherData.condition)) {
|
||||
nowIcon.classList.add('weather-snow');
|
||||
} else if (this.isClearSun(this.weatherData.iconCode, this.weatherData.condition)) {
|
||||
nowIcon.classList.add('weather-sun', 'weather-clear-sun');
|
||||
} else if (this.isSunBehindClouds(this.weatherData.iconCode, this.weatherData.condition)) {
|
||||
nowIcon.classList.add('weather-sun', 'weather-clouds-sun');
|
||||
} else if (this.isSunIcon(this.weatherData.iconCode, this.weatherData.condition)) {
|
||||
nowIcon.classList.add('weather-sun');
|
||||
}
|
||||
nowElement.innerHTML = `
|
||||
<div class="time">Nu</div>
|
||||
<div class="icon"></div>
|
||||
<div class="temp">${this.weatherData.temperature} °C</div>
|
||||
`;
|
||||
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);
|
||||
if (this.isSnowIcon(forecast.iconCode, forecast.condition)) {
|
||||
forecastIcon.classList.add('weather-snow');
|
||||
} else if (this.isClearSun(forecast.iconCode, forecast.condition)) {
|
||||
forecastIcon.classList.add('weather-sun', 'weather-clear-sun');
|
||||
} else if (this.isSunBehindClouds(forecast.iconCode, forecast.condition)) {
|
||||
forecastIcon.classList.add('weather-sun', 'weather-clouds-sun');
|
||||
} else if (this.isSunIcon(forecast.iconCode, forecast.condition)) {
|
||||
forecastIcon.classList.add('weather-sun');
|
||||
}
|
||||
forecastElement.innerHTML = `
|
||||
<div class="time">${timeString}</div>
|
||||
<div class="icon"></div>
|
||||
<div class="temp">${forecast.temperature} °C</div>
|
||||
`;
|
||||
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}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating weather UI:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sunrise and sunset times from API data
|
||||
*/
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
// Create Date objects from Unix timestamps
|
||||
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);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sunrise and sunset times using calculation
|
||||
*/
|
||||
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);
|
||||
|
||||
this.sunTimes = {
|
||||
today: this.calculateSunTimes(today),
|
||||
tomorrow: this.calculateSunTimes(tomorrow)
|
||||
};
|
||||
|
||||
console.log('Sun times updated from calculation:', this.sunTimes);
|
||||
return this.sunTimes;
|
||||
} catch (error) {
|
||||
console.error('Error updating sun times from calculation:', error);
|
||||
// Fallback to default times if calculation fails
|
||||
const defaultSunrise = new Date();
|
||||
defaultSunrise.setHours(6, 45, 0, 0);
|
||||
|
||||
const defaultSunset = new Date();
|
||||
defaultSunset.setHours(17, 32, 0, 0);
|
||||
|
||||
this.sunTimes = {
|
||||
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
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Export the WeatherManager class for use in other modules
|
||||
window.WeatherManager = WeatherManager;
|
||||
122
public/js/main.js
Normal file
122
public/js/main.js
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Main application entry point
|
||||
* Initializes all components when the DOM is ready
|
||||
*/
|
||||
|
||||
/**
|
||||
* Function to ensure content wrapper exists for rotated orientations
|
||||
*/
|
||||
function ensureContentWrapper() {
|
||||
if (!document.getElementById('content-wrapper')) {
|
||||
if (window.logger) {
|
||||
window.logger.info('Creating content wrapper');
|
||||
} else {
|
||||
console.log('Creating content wrapper');
|
||||
}
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.id = 'content-wrapper';
|
||||
|
||||
// Move all body children to the wrapper except excluded elements
|
||||
const excludedElements = ['config-button', 'config-modal', 'background-overlay'];
|
||||
|
||||
// Create an array of nodes to move (can't modify while iterating)
|
||||
const nodesToMove = [];
|
||||
for (let i = 0; i < document.body.children.length; i++) {
|
||||
const child = document.body.children[i];
|
||||
if (!excludedElements.includes(child.id) && child.id !== 'content-wrapper') {
|
||||
nodesToMove.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
// Move the nodes to the wrapper
|
||||
nodesToMove.forEach(node => {
|
||||
wrapper.appendChild(node);
|
||||
});
|
||||
|
||||
// Add the wrapper back to the body
|
||||
document.body.appendChild(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize components when the DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
if (window.logger) {
|
||||
window.logger.info('DOM fully loaded');
|
||||
} else {
|
||||
console.log('DOM fully loaded');
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize ConfigManager first
|
||||
if (window.logger) {
|
||||
window.logger.info('Creating ConfigManager...');
|
||||
} else {
|
||||
console.log('Creating ConfigManager...');
|
||||
}
|
||||
window.configManager = new ConfigManager({
|
||||
defaultOrientation: 'normal',
|
||||
defaultDarkMode: 'auto'
|
||||
});
|
||||
|
||||
// Note: ConfigManager already creates the config button and modal
|
||||
|
||||
// Initialize Clock
|
||||
const timezone = window.Constants?.TIMEZONE || 'Europe/Stockholm';
|
||||
window.clock = new Clock({
|
||||
elementId: 'clock',
|
||||
timezone: timezone
|
||||
});
|
||||
|
||||
// Initialize WeatherManager with location from window config or constants
|
||||
const defaultLat = window.DEFAULT_LOCATION?.latitude ||
|
||||
(window.Constants?.DEFAULT_LOCATION?.LATITUDE) || 59.3293;
|
||||
const defaultLon = window.DEFAULT_LOCATION?.longitude ||
|
||||
(window.Constants?.DEFAULT_LOCATION?.LONGITUDE) || 18.0686;
|
||||
window.weatherManager = new WeatherManager({
|
||||
latitude: defaultLat,
|
||||
longitude: defaultLon
|
||||
});
|
||||
|
||||
// Initialize departures - use DeparturesManager
|
||||
if (typeof DeparturesManager !== 'undefined') {
|
||||
window.departuresManager = new DeparturesManager({
|
||||
containerId: 'departures',
|
||||
statusId: 'status',
|
||||
lastUpdatedId: 'last-updated'
|
||||
});
|
||||
} else if (typeof initDepartures === 'function') {
|
||||
// Fallback to legacy function if DeparturesManager not available
|
||||
initDepartures();
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
document.addEventListener('darkModeChanged', event => {
|
||||
document.body.classList.toggle('dark-mode', event.detail.isDarkMode);
|
||||
});
|
||||
|
||||
document.addEventListener('configChanged', event => {
|
||||
if (['vertical', 'upsidedown', 'vertical-reverse'].includes(event.detail.config.orientation)) {
|
||||
ensureContentWrapper();
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure content wrapper exists initially
|
||||
ensureContentWrapper();
|
||||
|
||||
if (window.logger) {
|
||||
window.logger.info('All components initialized successfully');
|
||||
} else {
|
||||
console.log('All components initialized successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.logger) {
|
||||
window.logger.error('Error during initialization:', error);
|
||||
} else {
|
||||
console.error('Error during initialization:', error);
|
||||
}
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'error';
|
||||
errorDiv.textContent = `Initialization error: ${error.message}`;
|
||||
document.body.appendChild(errorDiv);
|
||||
}
|
||||
});
|
||||
89
public/js/utils/constants.js
Normal file
89
public/js/utils/constants.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Application constants and configuration values
|
||||
* Centralizes magic numbers, API endpoints, and default settings
|
||||
*/
|
||||
|
||||
const Constants = {
|
||||
// Server configuration
|
||||
PORT: 3002,
|
||||
API_BASE_URL: 'http://localhost:3002',
|
||||
|
||||
// API Endpoints
|
||||
ENDPOINTS: {
|
||||
DEPARTURES: '/api/departures',
|
||||
SITES_SEARCH: '/api/sites/search',
|
||||
SITES_NEARBY: '/api/sites/nearby',
|
||||
CONFIG_UPDATE: '/api/config/update'
|
||||
},
|
||||
|
||||
// External API URLs
|
||||
EXTERNAL_APIS: {
|
||||
SL_TRANSPORT_BASE: 'https://transport.integration.sl.se/v1',
|
||||
OPENWEATHERMAP_BASE: 'https://api.openweathermap.org/data/2.5'
|
||||
},
|
||||
|
||||
// Refresh intervals (in milliseconds)
|
||||
REFRESH: {
|
||||
DEPARTURES: 5000, // 5 seconds
|
||||
WEATHER: 30 * 60 * 1000, // 30 minutes
|
||||
DARK_MODE_CHECK: 60000 // 1 minute
|
||||
},
|
||||
|
||||
// Default location (Stockholm, Sweden)
|
||||
DEFAULT_LOCATION: {
|
||||
LATITUDE: 59.3293,
|
||||
LONGITUDE: 18.0686,
|
||||
NAME: 'Stockholm'
|
||||
},
|
||||
|
||||
// Default site configuration
|
||||
DEFAULT_SITE: {
|
||||
ID: '1411',
|
||||
NAME: 'Ambassaderna'
|
||||
},
|
||||
|
||||
// Default search radius (in meters)
|
||||
DEFAULT_SEARCH_RADIUS: 5000, // 5km
|
||||
|
||||
// Transport mode identifiers
|
||||
TRANSPORT_MODES: {
|
||||
BUS: 'bus',
|
||||
METRO: 'metro',
|
||||
TRAIN: 'train',
|
||||
TRAM: 'tram',
|
||||
SHIP: 'ship'
|
||||
},
|
||||
|
||||
// CSS class names (for consistency)
|
||||
CSS_CLASSES: {
|
||||
DARK_MODE: 'dark-mode',
|
||||
ORIENTATION_NORMAL: 'normal',
|
||||
ORIENTATION_LANDSCAPE: 'landscape',
|
||||
ORIENTATION_VERTICAL: 'vertical',
|
||||
ORIENTATION_UPSIDE_DOWN: 'upsidedown',
|
||||
ORIENTATION_VERTICAL_REVERSE: 'vertical-reverse'
|
||||
},
|
||||
|
||||
// Time thresholds (in minutes)
|
||||
TIME_THRESHOLDS: {
|
||||
URGENT: 5, // Less than 5 minutes is urgent (red)
|
||||
WITHIN_HOUR: 60 // Within next hour
|
||||
},
|
||||
|
||||
// LocalStorage keys
|
||||
STORAGE_KEYS: {
|
||||
CONFIG: 'sl-departures-config'
|
||||
},
|
||||
|
||||
// Timezone
|
||||
TIMEZONE: 'Europe/Stockholm',
|
||||
|
||||
// Date/time formats
|
||||
DATE_FORMAT: {
|
||||
TIMEZONE: 'Europe/Stockholm',
|
||||
LOCALE: 'sv-SE'
|
||||
}
|
||||
};
|
||||
|
||||
// Export constants
|
||||
window.Constants = Constants;
|
||||
100
public/js/utils/logger.js
Normal file
100
public/js/utils/logger.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Logger utility for consistent logging throughout the application
|
||||
* Supports different log levels and can be configured for production vs development
|
||||
*/
|
||||
|
||||
class Logger {
|
||||
constructor() {
|
||||
this.level = this.getLogLevel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current log level from environment or default to 'info'
|
||||
* @returns {string} Log level ('debug', 'info', 'warn', 'error')
|
||||
*/
|
||||
getLogLevel() {
|
||||
// In production, you might want to check window.location.hostname
|
||||
// or a global config. For now, default to 'info'
|
||||
if (typeof window !== 'undefined' && window.LOG_LEVEL) {
|
||||
return window.LOG_LEVEL;
|
||||
}
|
||||
// Default to 'info' level
|
||||
return 'info';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a log level should be output
|
||||
* @param {string} level - Log level to check
|
||||
* @returns {boolean} Whether to output this level
|
||||
*/
|
||||
shouldLog(level) {
|
||||
const levels = ['debug', 'info', 'warn', 'error'];
|
||||
const currentLevelIndex = levels.indexOf(this.level);
|
||||
const messageLevelIndex = levels.indexOf(level);
|
||||
return messageLevelIndex >= currentLevelIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a log message with timestamp
|
||||
* @param {string} level - Log level
|
||||
* @param {string} message - Message to log
|
||||
* @param {any[]} args - Additional arguments
|
||||
* @returns {string} Formatted message
|
||||
*/
|
||||
formatMessage(level, message, args = []) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
|
||||
return args.length > 0 ? [prefix, message, ...args] : [prefix, message];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a debug message
|
||||
* @param {string} message - Message to log
|
||||
* @param {...any} args - Additional arguments
|
||||
*/
|
||||
debug(message, ...args) {
|
||||
if (this.shouldLog('debug')) {
|
||||
console.debug(...this.formatMessage('debug', message, args));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an info message
|
||||
* @param {string} message - Message to log
|
||||
* @param {...any} args - Additional arguments
|
||||
*/
|
||||
info(message, ...args) {
|
||||
if (this.shouldLog('info')) {
|
||||
console.info(...this.formatMessage('info', message, args));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a warning message
|
||||
* @param {string} message - Message to log
|
||||
* @param {...any} args - Additional arguments
|
||||
*/
|
||||
warn(message, ...args) {
|
||||
if (this.shouldLog('warn')) {
|
||||
console.warn(...this.formatMessage('warn', message, args));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error message
|
||||
* @param {string} message - Message to log
|
||||
* @param {...any} args - Additional arguments
|
||||
*/
|
||||
error(message, ...args) {
|
||||
if (this.shouldLog('error')) {
|
||||
console.error(...this.formatMessage('error', message, args));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
const logger = new Logger();
|
||||
|
||||
// Export both the class and the singleton instance
|
||||
window.Logger = Logger;
|
||||
window.logger = logger;
|
||||
Reference in New Issue
Block a user