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.

Monkey Testing Web Apps Using Gremlins.js

Gremlins.js is a monkey testing library written in JavaScript. It is incredibly lightweight and perfect for your quick test runs. Find the repository on GitHub.

What it does

To be honest, I couldn’t describe the purpose of the library any better than they do themselves:

Use it to check the robustness of web applications by unleashing a horde of undisciplined gremlins.

That’s a nice piece of imagery they use there, but you’ll notice how accurate it is once you get your hands on the actual code.

Feel free to compare a ‘gremlin’ with a monkey in the monkey testing context. The gremlin represents a user going absolutely nuts on your web application. I always imagine a monkey sitting behind a computer, bashing on all possible keys of the keyboard in front. Include all sort of clicking, scrolling and other mouse actions in your picture.

Gremlins performing visible click and scroll actions on a website.
Gremlins performing visible click and scroll actions on a website.

Imagine what such a user might have as impact on any website. Will such a user be able to crash the system our pull off a bunch of unexpected things? With gremlins.js you can now be sure of what is possible.

Unleash the Horde

Getting started is quick and straight-forward. In no time you can have multiple gremlin species deployed on your web page. The developers have been so kind to provide us with some examples.

To get you started in Google Chrome, open up your website under test and hit F12. This will open the Developer Tools. In the Developer Tools, open up the tab ‘Sources’ and navigate to ‘Snippets’. Right click and select ‘New’. A blank snippet will be created. In this snippet, copy the contents of gremlins.min.js, this is a mandatory step.

Prepare 2 more blank snippets. In order to have some basic functions on top of gremlins, paste the content of this PasteBin paste in a snippet. For the last snippet you can use the content of another PasteBin paste. This last snippet will prepare all you need to start spamming your application.

Initialization of a gremlins horde.
Initialization of a gremlins horde.

Species, are a thing in gremlins.js. Each gremlin species has some responsibility, such as clicking or scrolling. For every action you aim to test you should create a species and add it to the horde you are going to release later on.

Over to the real action then! From the Developer Tools run the first snippet (the gremlins.min.js script). Now do the same thing for the two other scripts, make sure you get the order right (gremlins, functions, launch). From the moment you run the last script, the gremlins horde will be released onto your web app to cause absolute mayhem (let’s hope they don’t).

Now customization is up to you. I have also created a different script that does exactly the same that we just did, except that it hides all gremlins’ actions. Find it here.

I thought I’d share this little library, since it is a unique little piece of software that is very easy to setup and remarkable in the way you set it up.