Building the Belgian Train Station Chrome Extension

A while ago, I built my very first Chrome web extension, ‘Belgian Train Station‘.
I felt like playing around with Javascript for a bit again. Building a basic extension that solved a small problem seemed the perfect way to do that!

Let me give you an overview of the project.

 

The problem

To be honest, there is no real big problem at hand here. In Belgium, where I live, the national railway company is called NMBS. This company, like any other self-respecting national railway company, has their own website where you can perform searches to plan your journey. However, it seems like there is no real good overview available to view time tables for arriving and/or departing trains for a specific train station.

iRail does have something like that, however, the displayed information is very concise (they call it ‘Liveboard’).

Looking at iRail’s solution, I noticed that they use a free and publicly available API. So I read through their documentation.

Belgian Train Station Logo
The logo of Belgian Train Station

The solution

I had only a few requirements;

  • add translations in the country’s 3 official languages (Dutch, French and German) as well as English
  • keep it simple
  • show all relevant data for departures and arrivals.

A simple Chrome extension such as this one only really needs one basic index.html. So I created one that merely contains all the necessary skeleton and references to the stylesheet and scripts.

The index.html:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Belgian Train Station</title>

<link rel="stylesheet" href="styles.css">
<script src="utils.js"></script>
<script src="dataService.js"></script>
<script src="popup.js"></script>
</head>
<body>
<h1 id="titleHeader">Belgian Train Station</h1>

<div id="clock">
<span id="hours"></span>
:
<span id="minutes"></span>
:
<span id="seconds"></span>
</div>

<div id="inputContainer">
<div>
<label id="movementTypeLabel" for="movementType" class="formLabel">Movement type</label>
<select id="movementType" name="movementType"></select>
</div>
<div>
<label id="stationNameLabel" for="stationName" class="formLabel">Station name</label>
<input id="stationName" name="stationName" type="search">
<button id="clearSearch" hidden="hidden">Clear</button>
</div>
</div>

<div id="loader" class="loading-state">
<div class="loading-black"></div>
<div class="loading-yellow"></div>
<div class="loading-red"></div>
</div>

<div id="liveBoardContainer">
<div id="liveBoard"></div>
</div>

<br>
<a id="donationLink" href="https://www.buymeacoffee.com/Thibstars" target="_blank" tabindex="-1"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 45px !important;width: 180px !important;" ></a>
<br>
<a id="version" target="_blank" aria-label="Version"></a>
<div id="themeInputContainer">
<label id="themeLabel" for="theme" class="formLabel"></label>
<select id="theme" name="theme"></select>
</div>
</body>
</html>

I structured the JS code into 3 main scripts: dataService.js, utils.js and popup.js.
The first one would house all code related to fetching data via the iRail API, the second one would hold any common utilities I deemed useful, the popup script is meant to contain all displaying logic.

dataService.js

const API_REQUEST_INIT =         {
headers: {
'user-agent': 'Belgian Train Station (https://github.com/Thibstars/belgian-train-station-chrome)',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PATCH, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Origin, Content-Type, X-Auth-Token'
}
};

const API_BASE_URL = 'https://api.irail.be';

const SUPPORTED_API_LANGUAGES = ['en', 'nl', 'de', 'fr'];

const MOVEMENT_TYPE = {
DEPARTURE: Symbol.for('departure'),
ARRIVAL: Symbol.for('arrival')
}

function determineAPILanguageFromUILocale(i18n) {
const uiLanguage = getMessage(i18n, '@@ui_locale').substring(0, 2); // Only interested in the 2 first chars

return SUPPORTED_API_LANGUAGES.includes(uiLanguage) ? uiLanguage : 'en'; // Get matching lang or default to en
}

async function getRandomStation(i18n) {
const response = await fetch(
API_BASE_URL + '/stations/?format=json&lang=' + determineAPILanguageFromUILocale(i18n),
API_REQUEST_INIT
);
const data = await response.json();

const stations = data.station;

return stations[Math.floor(Math.random() * stations.length)];
}

async function getLiveBoard(i18n, stationName, movementType) {
let movement;

switch (movementType) {
case MOVEMENT_TYPE.DEPARTURE:
movement = Symbol.keyFor(MOVEMENT_TYPE.DEPARTURE);
break;
case MOVEMENT_TYPE.ARRIVAL:
movement = Symbol.keyFor(MOVEMENT_TYPE.ARRIVAL);
break
default:
movement = Symbol.keyFor(MOVEMENT_TYPE.DEPARTURE);
}

const response = await fetch(
API_BASE_URL + '/liveboard/?station=' + stationName + '&format=json&lang=' + determineAPILanguageFromUILocale(i18n) + '&arrdep=' + movement,
API_REQUEST_INIT
);

return await response.json();
}

utils.js

// Use this method to avoid inspection warnings on i18n's getMessage method
function getMessage(i18n, key) {
let result = key;
if (i18n) {
// noinspection JSUnresolvedReference
result = i18n.getMessage(key) ? i18n.getMessage(key) : key;
}

return result;
}

// Use this method to avoid inspection warnings on chrome's runtime.getManifest method
function getManifest() {
// noinspection JSUnresolvedReference
return chrome.runtime.getManifest();
}

function storeValueInGlobalStorage(key, value) {
// noinspection JSUnresolvedReference
return chrome.storage.sync.set({[key]: value});
}

function getValueFromGlobalStorage(key) {
// noinspection JSUnresolvedReference
return chrome.storage.sync.get([key]);
}

popup.js

const THEME = {
LIGHT: Symbol.for('light'),
DARK: Symbol.for('dark'),
HIGH_CONTRAST: Symbol.for('high_contrast'),
};

let liveBoardRefreshTimer;
const LIVE_BOARD_REFRESH_INTERVAL = 60 * 1000;

async function loadLiveBoardForStation(i18n, stationName, movementType) {
const liveBoard = document.getElementById('liveBoard');
liveBoard.setAttribute('data-movement-type', Symbol.keyFor(movementType));
const clearSearch = document.getElementById('clearSearch');
clearSearch.hidden = false;
removeStationDataClarifierIfPresent();

liveBoard.replaceChildren(document.createTextNode(getMessage(i18n, 'fetchingData') + stationName));

showLoader();

return getLiveBoard(i18n, stationName, movementType);
}

function removeStationDataClarifierIfPresent() {
const stationDataClarifier = document.getElementById('stationDataClarifier');
if (stationDataClarifier) {
stationDataClarifier.remove();
}
}

function showLoader() {
const loader = document.getElementById('loader');
loader.setAttribute('class', 'loading-state');
loader.children[0].hidden = false;
loader.children[1].hidden = false;
loader.children[2].hidden = false;
}

function hideLoader() {
const loader = document.getElementById('loader');
loader.removeAttribute('class');
loader.children[0].hidden = 'hidden';
loader.children[1].hidden = 'hidden';
loader.children[2].hidden = 'hidden';
}

function createMovementTable(liveBoard, i18n, stationName, movements) {
const movementsTable = document.createElement('table');
movementsTable.id = 'liveBoardTable';
movementsTable.setAttribute('data-station-name', stationName);
const headerRow = document.createElement('tr');
const thCanceled = document.createElement('th');
thCanceled.replaceChildren(document.createTextNode(getMessage(i18n, 'canceled')));
const thDelay = document.createElement('th');
thDelay.title = getMessage(i18n, 'delayTitle');
thDelay.replaceChildren(document.createTextNode(getMessage(i18n, 'delay')));
const thPlatform = document.createElement('th');
thPlatform.replaceChildren(document.createTextNode(getMessage(i18n, 'platform')));
const thStation = document.createElement('th');
thStation.replaceChildren(document.createTextNode(getMessage(i18n, 'station')));
const thTime = document.createElement('th');
thTime.replaceChildren(document.createTextNode(getMessage(i18n, 'time')));
headerRow.replaceChildren(
thCanceled,
thDelay,
thPlatform,
thStation,
thTime,
);
movementsTable.replaceChildren(headerRow);

for (const movement of movements) {
const delayInMinutes = movement.delay / 60;
const time = new Date(movement.time * 1000);
const isCanceled = movement.canceled !== '0';
const isDelayed = delayInMinutes > 0;
const isUnknownPlatform = movement.platform === '?';

const trMovement = document.createElement('tr');
movementsTable.appendChild(trMovement);

const tdCanceled = document.createElement('td');
tdCanceled.className = isCanceled ? 'canceled' : '';
tdCanceled.title = isCanceled ? getMessage(i18n, 'trainCanceled') : '';
tdCanceled.replaceChildren(document.createTextNode(!isCanceled ? getMessage(i18n, 'no') : getMessage(i18n, 'yes')));
const tdDelay = document.createElement('td');
tdDelay.className = isDelayed ? 'delayed' : '';
tdDelay.replaceChildren(document.createTextNode(isDelayed ? delayInMinutes.toString() : '-'));
const tdPlatform = document.createElement('td');
tdPlatform.className = isUnknownPlatform ? 'unknownPlatform' : '';
tdPlatform.title = isUnknownPlatform ? getMessage(i18n, 'unknownPlatform') : '';
tdPlatform.replaceChildren(document.createTextNode(movement.platform));
const tdStation = document.createElement('td');
tdStation.className = 'clickableCell';
tdStation.replaceChildren(document.createTextNode(movement.station));
const loadAndShowLiveBoard = () => {
const selectedStationName = tdStation.innerText;

let movementType = getSelectedMovementType()

loadLiveBoardForStation(i18n, selectedStationName, movementType).then(
(data) => {
showLiveBoard(i18n, selectedStationName, data, liveBoard);
document.getElementById('stationName').value = selectedStationName;
}
).catch(
() => {
showNoResults(liveBoard, i18n, selectedStationName);
}
);
}
tdStation.onclick = function () {
clearInterval(liveBoardRefreshTimer);
loadAndShowLiveBoard();
liveBoardRefreshTimer = setInterval(loadAndShowLiveBoard, LIVE_BOARD_REFRESH_INTERVAL);
};
const tdTime = document.createElement('td');
tdTime.title = time.toLocaleString();
tdTime.replaceChildren(document.createTextNode(time.toLocaleTimeString()));
trMovement.replaceChildren(
tdCanceled,
tdDelay,
tdPlatform,
tdStation,
tdTime,
);
}

return movementsTable;
}

function setStaticMessages(i18n) {
const title = getMessage(i18n, 'extensionName');
document.title = title;
document.getElementById('titleHeader').innerText = title;

document.getElementById('themeLabel').innerText = getMessage(i18n, 'theme');

document.getElementById('movementTypeLabel').innerText = getMessage(i18n, 'movementType');

document.getElementById('movementType').title = getMessage(i18n, 'movementTypeHelp');

document.getElementById('stationNameLabel').innerText = getMessage(i18n, 'stationName');

document.getElementById('stationName').title = getMessage(i18n, 'stationNameHelp');

document.getElementById('clearSearch').innerText = getMessage(i18n, 'clear');
}

function showLiveBoard(i18n, stationName, data, liveBoard) {
const movementType = Symbol.for(liveBoard.getAttribute('data-movement-type'));

let movements = document.createElement('div');
movements.id = 'movements';
const numberSpan = document.createElement('span');
numberSpan.id = 'movementAmount';
let movementData;

switch (movementType) {
case MOVEMENT_TYPE.DEPARTURE:
movements.replaceChildren(
document.createTextNode(getMessage(i18n, 'departures')),
numberSpan
);
numberSpan.innerText = data.departures.number;
movementData = data.departures.departure;
break;
case MOVEMENT_TYPE.ARRIVAL:
movements.replaceChildren(
document.createTextNode(getMessage(i18n, 'arrivals')),
numberSpan
);
numberSpan.innerText = data.arrivals.number;

movementData = data.arrivals.arrival;
break
default:
movements.replaceChildren(
document.createTextNode(getMessage(i18n, 'departures')),
numberSpan
);
numberSpan.innerText = data.departures.number;
movementData = data.departures.departure;
}

const movementTable = createMovementTable(liveBoard, i18n, stationName, movementData);

const lastUpdated = document.createElement('span');
lastUpdated.innerText = getMessage(i18n, 'lastUpdated') + ': ' + new Date().toLocaleTimeString();

liveBoard.replaceChildren(
movements,
document.createElement('br'),
movementTable,
lastUpdated
);

hideLoader();
}

function showNoResults(liveBoard, i18n, stationName) {
liveBoard.replaceChildren(document.createTextNode(getMessage(i18n, 'noResults') + stationName));

hideLoader();
}

function showStationDataClarifier(clearSearch, i18n) {
clearSearch.hidden = 'hidden';
const stationName = document.getElementById('liveBoardTable').getAttribute('data-station-name');
const stationDataClarifier = document.createElement('span');
stationDataClarifier.setAttribute('id', 'stationDataClarifier')
stationDataClarifier.innerText = getMessage(i18n, 'stationDataClarification') + ' ' + stationName;
const liveBoardContainer = document.getElementById('liveBoardContainer');
liveBoardContainer.insertBefore(stationDataClarifier, liveBoardContainer.children[0]);
}

function getSelectedMovementType() {
const movementTypeSelect = document.getElementById('movementType');

switch (Symbol.for(movementTypeSelect.value.toLowerCase())) {
case MOVEMENT_TYPE.DEPARTURE :
return MOVEMENT_TYPE.DEPARTURE;
case MOVEMENT_TYPE.ARRIVAL :
return MOVEMENT_TYPE.ARRIVAL;
default:
return MOVEMENT_TYPE.DEPARTURE;
}
}

function switchThemes(theme, animateTransition) {
document.documentElement.setAttribute('data-theme', theme);
if (animateTransition) {
document.documentElement.classList.add('color-theme-in-transition');
window.setTimeout(function () {
document.documentElement.classList.remove('color-theme-in-transition')
}, 1000);
}
}

function showClock() {
const clock = document.getElementById('clock');
const hours = document.getElementById('hours');
const minutes = document.getElementById('minutes');
const seconds = document.getElementById('seconds');

showTime(clock, hours, minutes, seconds);

setInterval(() => {
showTime(clock, hours, minutes, seconds);
},
1000
);
}

function showTime(clock, hours, minutes, seconds) {
const date = new Date();
if (!clock.matches(':hover')) {
// Do not constantly update the tooltip while showing it (it will flash)
clock.title = date.toLocaleString();
}
hours.innerText = date.getHours().toString().padStart(2, "0");
minutes.innerText = date.getMinutes().toString().padStart(2, "0");
seconds.innerText = date.getSeconds().toString().padStart(2, "0");
}

document.addEventListener('DOMContentLoaded', function () {
getValueFromGlobalStorage('theme').then((result) => {
const themeToUse = result.theme ? result.theme : 'light';
document.getElementById('theme').value = themeToUse.toUpperCase();
switchThemes(themeToUse, false);
});

const loader = document.getElementById('loader');
loader.removeAttribute('class');
loader.children[0].hidden = 'hidden';
loader.children[1].hidden = 'hidden';
loader.children[2].hidden = 'hidden';

// noinspection JSUnresolvedReference
const i18n = chrome.i18n;
setStaticMessages(i18n);
showClock();

const themeSelect = document.getElementById('theme');
for (let theme in THEME) {
const option = document.createElement('option');
option.value = theme;
option.innerText = getMessage(i18n, 'theme_' + theme);
themeSelect.appendChild(option);
}

const movementTypeSelect = document.getElementById('movementType');
movementTypeSelect.replaceChildren();
for (let key in MOVEMENT_TYPE) {
const option = document.createElement('option');
option.value = key;
option.innerText = getMessage(i18n, 'movementType_' + key);
movementTypeSelect.appendChild(option);
}

const stationNameInput = document.getElementById('stationName');
getRandomStation(i18n).then((randomStation) => {
stationNameInput.placeholder = randomStation.name;
}).catch(() => {
stationNameInput.placeholder = '';
});

const manifestData = getManifest();
const versionLink = document.getElementById('version');
const version = manifestData.version;
versionLink.innerText = getMessage(i18n, 'version') + ' ' + version;
versionLink.setAttribute('href', 'https://github.com/Thibstars/belgian-train-station-chrome/releases/tag/' + version);
versionLink.title = getMessage(i18n, 'release');

const clearSearch = document.getElementById('clearSearch');
clearSearch.hidden = 'hidden';
clearSearch.addEventListener('click', function () {
document.getElementById('liveBoard').replaceChildren();
stationNameInput.value = '';
clearSearch.hidden = 'hidden'

getRandomStation(i18n).then(
(randomStation) => {
stationNameInput.placeholder = randomStation.name;
}
).catch(() => {
stationNameInput.placeholder = '';
});
});

let typingTimer;
stationNameInput.addEventListener('input', function () {
clearTimeout(typingTimer);
typingTimer = setTimeout(() => {
clearInterval(liveBoardRefreshTimer);
const loadAndShowLiveBoard = () => {
if (stationNameInput.value) {
let movementType = getSelectedMovementType();

const liveBoard = document.getElementById('liveBoard');
loadLiveBoardForStation(i18n, stationNameInput.value, movementType).then(
(data) => {
showLiveBoard(i18n, stationNameInput.value, data, liveBoard);
}
).catch(
() => {
showNoResults(liveBoard, i18n, stationNameInput.value);
}
);
} else {
showStationDataClarifier(clearSearch, i18n);
}
};
loadAndShowLiveBoard();
liveBoardRefreshTimer = setInterval(loadAndShowLiveBoard, LIVE_BOARD_REFRESH_INTERVAL);
}, 500);
});

themeSelect.addEventListener('change', () => {
const selectedTheme = themeSelect.value.toLowerCase();

storeValueInGlobalStorage('theme', selectedTheme);

return switchThemes(selectedTheme, true);
})

movementTypeSelect.addEventListener('change', () => {
const liveBoard = document.getElementById('liveBoard');
const liveBoardTable = document.getElementById('liveBoardTable');

clearInterval(liveBoardRefreshTimer);
const loadAndShowLiveBoard = () => {
if (liveBoardTable) {
const stationName = liveBoardTable.getAttribute('data-station-name');
if (stationName) {
const stationDataClarifier = document.getElementById('stationDataClarifier');

loadLiveBoardForStation(i18n, stationName, getSelectedMovementType()).then(
(data) => {
showLiveBoard(i18n, stationName, data, liveBoard);
if (stationDataClarifier) {
showStationDataClarifier(clearSearch, i18n);
}
}
).catch(
() => {
showNoResults(liveBoard, i18n, stationName);
}
);
}
}
};
loadAndShowLiveBoard();
liveBoardRefreshTimer = setInterval(loadAndShowLiveBoard, LIVE_BOARD_REFRESH_INTERVAL);
});

}, false);

Now for the translations! This is a cornerstone feature of the application, as in Belgium, localisation is always important (3 national languages!).

Getting messages can be done like so:

getMessage(i18n, 'departures')

where ‘departures’ represents a translation key. For each language we need a directory (‘en’ for English for example) containing a messages.json file.
That file contains a simple JSON structure containing all the translation keys and there actual value. So in the case of the ‘departures’ example, such a pair would look like this:

"departures": {
"message": "Departures",
"description": "Label for the amount of departures."
}


After having implemented the above, a manifest.json file is required to glue it all together and to provide some packaging details:

{
"name": "Belgian Train Station",
"description": "Get updates for a Belgian train station.",
"version": "1.1.3",
"manifest_version": 3,
"action": {
"default_popup": "index.html",
"default_icon": "icon.png"
},
"default_locale": "en",
"icons": {
"48": "icon.png"
},
"host_permissions": ["https://api.irail.be/*"],
"permissions": ["storage"],
"author": "Thibault Helsmoortel"
}

How it works

Easy! After installation, just open the extension by clicking on the icon. A popup will appear. You can then select wether you’d like to view departures or arrivals and enter the name of the train station you want to view the live data for. That’s it! No more need for a quick Google. There is a official NMBS app, but they don’t offer a view like this one.


For the rest, I’ll leave you with the magic of experiencing the little tool yourself.
I do hope you’ll enjoy the theme (light/dark) mechanism and the automatic language detection. The code is open-source, so feel free to report any issues or contribute to the project. I’m sure there is room for improvement (I’m thinking of a station selection view for example).


Happy travelling and train spotting!


Find the project on GitHub
Find the loading animations on CodePen.

Adjust Loading Animations in Vaadin

By default, loading animations in Vaadin are already pretty awesome, though you might want to adjust them to make them fit better with your custom theme.

While tweaking around with the animations myself, I found that there wasn’t too much information to find about it around the web, so here’s me contributing.

It’s All CSS

To adjust the loading animations, there’s actually no need at all to fiddle in Java code. Everything can be easily set up in css. You only need to edit one file, which would be your custom theme’s scss file.

First of all, it is important to know that the styling for the animations is not located under the .v-app class. All the editing can be done top-level, right after the base theme include statement. For the Valo theme, this would be right after the following line: @include valo;.

Top Loading Indicator

In this example I will cover a minor tweak to the loading bar you see on the top of the page, which by default is blue. It is displayed upon page navigation.

To simply adjust the color you can use following code snippet, where all style attributes are just coppied from the default styling. The only property you’ll want to tweak will be the background-color one.

.v-loading-indicator {
    position: fixed !important;
    z-index: 99999;
    left: 0;
    right: auto;
    top: 0;
    width: 50%;
    opacity: 1;
    height: 4px;
    background-color: red;
    pointer-events: none;
    -webkit-transition: none;
    -moz-transition: none;
    transition: none;
    -webkit-animation: v-progress-start 1000ms 200ms both;
    -moz-animation: v-progress-start 1000ms 200ms both;
    animation: v-progress-start 1000ms 200ms both;
}

Connection Lost Spinner

Since we’re at it, we might as well adjust the color of the spinner that shows when the connection was lost. Once again, this snippet must be placed outside the .v-app class. Obviously, if you want to adjust all spinners application-wide, apply the styling to the .spinner class only.

The resulting box with spinner.
The resulting box with spinner.

.v-reconnect-dialog .spinner {
    border-top-color: red;
    border-right-color: red;
}

Centered Loading Spinner

Vaadin’s default spinner is shown upon page refresh, for instance. At least in the Valo theme, it is relatively small. Its’ styling is somewhat basic, so it might be good to give it a more customized touch, like the spinner below.

A custom loading spinner.
A custom loading spinner.

Right along the previously provided css snippet, another one can be placed. Following example shows how to replace the default spinner with our own.

.v-app-loading::before {
    opacity: .8; 
    filter: alpha(opacity=80); 
    width: 100px; height: 100px; 
    background: transparent url(../customtheme/img/spinner.gif); 
}

Update the Vaadin Theme

After these small tweaks in this one file you are all set and ready to check out the result of the applied changes.

Keep in mind that you will need to update the theme first. Vaadin has this Maven plugin com.vaadin:vaadin-maven-plugin:8.0.0, that you can add to your pom.xml. Once added, you can simply update and compile the theme and rebuild your project. Besides that, you will probably have to clear your browser’s cache before reloading your application. If you don’t do that, a previous version of your theme’s css file will be used.