/** * 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 ''; } const icons = { bus: '', metro: '', train: '', tram: '', ship: '' }; 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 = `
${transportIcon} ${departure.line.designation} ${departure.destination} ${timeDisplay} (${countdownText})
`; 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 = `
${transportIcon}
${group.lineNumber}
`; 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 = '
No departures found
'; 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 = `${site.siteName || siteConfig.name}`; 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 = `

Failed to load departures. Please try again later.

Error: ${error.message}

Make sure the Node.js server is running: node server.js

`; } } /** * 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;