- Item 10: Convert to ES modules with import/export, single module entry point - Item 11: Replace inline styles with CSS classes (background overlay, card animations, highlight effect, config modal form elements) - Item 12: Move ConfigManager modal HTML from JS template literal to <template> element in index.html - Item 13: Replace deprecated url.parse() with new URL() in server.js and update route handlers to use searchParams - Item 14: Replace JSON.parse/stringify deep clone with structuredClone() - Item 15: Remove dead JSON-fixing regex code from departures.js route Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
635 lines
27 KiB
JavaScript
635 lines
27 KiB
JavaScript
/**
|
|
* 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 = 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 = `<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();
|
|
}
|
|
}
|
|
|
|
// ES module export
|
|
export { DeparturesManager };
|
|
|
|
// Keep window reference for backward compatibility
|
|
window.DeparturesManager = DeparturesManager;
|