/**
* 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 = `
`;
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 = structuredClone(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.classList.add('card-entering');
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]);
}
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.classList.add('card-leaving');
card.addEventListener('transitionend', () => {
card.remove();
}, { once: true });
// Fallback removal if transitionend doesn't fire
setTimeout(() => card.remove(), 600);
}
});
}
/**
* 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.classList.remove('highlight-flash');
// Force reflow to restart animation
void element.offsetWidth;
element.classList.add('highlight-flash');
}
/**
* 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();
}
}
// ES module export
export { DeparturesManager };
// Keep window reference for backward compatibility
window.DeparturesManager = DeparturesManager;