diff --git a/index.html b/index.html
index d95fb2e..04b658c 100644
--- a/index.html
+++ b/index.html
@@ -20,7 +20,7 @@
diff --git a/station.html b/station.html
new file mode 100644
index 0000000..9c2b499
--- /dev/null
+++ b/station.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+ Station - InfoTren
+
+
+
+
+
+
+
+
+
+
+
+ Station Information
+
+ Station Name
+
+
+ Suggestions
+
+
+
+
+
diff --git a/station.js b/station.js
new file mode 100755
index 0000000..9ae78f2
--- /dev/null
+++ b/station.js
@@ -0,0 +1,211 @@
+var knownStations = []
+
+function goToStation(station) {
+ var url = new URL(window.location.href)
+ url.pathname = 'view-station.html'
+ url.searchParams.set('station', station)
+ url.searchParams.set('date', new Date().toISOString())
+ window.location.href = url.toString()
+}
+
+function searchNormalize(str) {
+ return str
+ .toLowerCase()
+ .replace('ă', 'a')
+ .replace('â', 'a')
+ .replace('î', 'i')
+ .replace('ș', 's')
+ .replace('ț', 't')
+}
+
+var focusedElement = null
+
+var _rebuildDebounce = null
+var _rebuildRequested = false
+function rebuildSuggestions() {
+ if (_rebuildDebounce !== null) {
+ _rebuildRequested = true
+ return
+ }
+
+ _rebuildRequested = false
+ _rebuildDebounce = 123
+
+ var suggestionsArea = document.getElementById('suggestionsArea')
+ while (suggestionsArea.childNodes.length > 0) {
+ suggestionsArea.childNodes[0].remove()
+ }
+
+ var stationNameInput = document.getElementById('stationName')
+ var stationName = searchNormalize(stationNameInput.value.trim())
+
+ var suggestions = []
+ if (!stationName) {
+ suggestions = knownStations.slice()
+ }
+ else {
+ for (var i = 0; i < knownStations.length; i++) {
+ if (!searchNormalize(knownStations[i].name).includes(stationName)) {
+ continue
+ }
+ suggestions.push(knownStations[i])
+ }
+ suggestions.sort((s1, s2) => {
+ var s1n = searchNormalize(s1.name);
+ var s2n = searchNormalize(s2.name);
+
+ if (s1n.indexOf(stationName) != s2n.indexOf(stationName)) {
+ return s1n.indexOf(stationName) - s2n.indexOf(stationName);
+ }
+
+ if (s1.stoppedAtBy.length != s2.stoppedAtBy.length) {
+ return s2.stoppedAtBy.length - s1.stoppedAtBy.length;
+ }
+
+ return s1.name.localeCompare(s2.name);
+ })
+ }
+
+ var foundInput = false
+ suggestions.forEach(function (suggestion, index) {
+ if (stationName == searchNormalize(suggestion.name)) {
+ foundInput = true
+ }
+ var suggestionLi = document.createElement('li')
+ suggestionsArea.appendChild(suggestionLi)
+
+ setTimeout(function () {
+ suggestionLi.classList.add('items')
+ suggestionLi.tabIndex = index + 1
+ suggestionLi.style.padding = '2px 0'
+
+ function onAction(e) {
+ goToStation(suggestion.name)
+ }
+ suggestionLi.addEventListener('click', onAction)
+ suggestionLi.addEventListener('keypress', function (e) {
+ if (e.key == 'Enter') {
+ onAction(e)
+ }
+ })
+ suggestionLi.addEventListener('focus', function (e) {
+ focusedElement = suggestionLi
+ })
+
+ var stationNameP = document.createElement('p')
+ suggestionLi.appendChild(stationNameP)
+
+ stationNameP.textContent = suggestion.name
+ stationNameP.classList.add('pri', 'stationName')
+
+ // var trainCompanyP = document.createElement('p')
+ // suggestionLi.appendChild(trainCompanyP)
+
+ // trainCompanyP.textContent = suggestion.company
+ // trainCompanyP.classList.add('thi')
+ }, 0)
+ })
+ if (!foundInput && stationName) {
+ var suggestionLi = document.createElement('li')
+ suggestionsArea.appendChild(suggestionLi)
+
+ suggestionLi.classList.add('items')
+ suggestionLi.tabIndex = suggestions.length + 2
+ suggestionLi.style.padding = '2px 0'
+
+ function onAction(e) {
+ goToStation(stationNameInput.value.trim())
+ }
+ suggestionLi.addEventListener('click', onAction)
+ suggestionLi.addEventListener('keypress', function (e) {
+ if (e.key == 'Enter') {
+ onAction(e)
+ }
+ })
+ suggestionLi.addEventListener('focus', function (e) {
+ focusedElement = suggestionLi
+ })
+
+ var stationNameP = document.createElement('p')
+ suggestionLi.appendChild(stationNameP)
+
+ stationNameP.textContent = stationNameInput.value.trim()
+ stationNameP.classList.add('pri', 'stationName')
+ }
+
+ setTimeout(function () {
+ _rebuildDebounce = null
+ if (_rebuildRequested) {
+ rebuildSuggestions()
+ }
+ }, 500)
+}
+
+function lsk() {
+ document.getElementById('stationName').focus()
+}
+
+function csk() {
+ if (focusedElement == null) {
+ return
+ }
+
+ if (focusedElement.id === 'stationName') {
+ goToTrain(document.activeElement.value.trim())
+ }
+ else {
+ focusedElement.click()
+ }
+}
+
+window.addEventListener('load', function (e) {
+ var stationName = document.getElementById('stationName')
+ stationName.addEventListener('input', function (e) {
+ rebuildSuggestions()
+ })
+ stationName.addEventListener('focus', function (e) {
+ focusedElement = stationName
+ document.getElementsByClassName('lsk')[0].textContent = ''
+ document.getElementsByClassName('csk')[0].textContent = 'Search'
+ })
+ stationName.addEventListener('blur', function (e) {
+ document.getElementsByClassName('lsk')[0].textContent = 'Search'
+ document.getElementsByClassName('csk')[0].textContent = 'Select'
+ })
+ stationName.addEventListener('keypress', function (e) {
+ if (e.key == 'Enter') {
+ goToStation(stationName.value.trim())
+ }
+ })
+
+ document.querySelectorAll('.lsk').forEach(function (lskElem) {
+ lskElem.addEventListener('click', function (e) {
+ lsk()
+ })
+ })
+ document.querySelectorAll('.csk').forEach(function (cskElem) {
+ cskElem.addEventListener('click', function (e) {
+ csk()
+ })
+ })
+ document.body.addEventListener('keydown', function (e) {
+ if (e.key == 'SoftLeft') {
+ lsk()
+ }
+ else if (e.key == 'Enter') {
+ csk()
+ }
+ })
+
+ fetch('https://scraper.infotren.dcdev.ro/v3/stations')
+ .then(function (response) {
+ return response.json()
+ })
+ .then(function (response) {
+ knownStations = response
+ knownStations.sort(function(a, b) { return b.stoppedAtBy.length - a.stoppedAtBy.length })
+ })
+ .then(function () {
+ rebuildSuggestions()
+ })
+})
diff --git a/sw.js b/sw.js
index e64df91..9a75023 100755
--- a/sw.js
+++ b/sw.js
@@ -16,6 +16,7 @@ self.addEventListener('install', (event) => {
'/common/worker.js',
'/common/items.js',
'/common/back.js',
+ '/common/tabs.js',
// Base
'/base.css',
@@ -32,6 +33,13 @@ self.addEventListener('install', (event) => {
'/view-train.js',
'/view-train.css',
+ '/station.html',
+ '/station.js',
+
+ '/view-station.html',
+ '/view-station.js',
+ '/view-station.css',
+
// API
API_TRAINS,
API_STATIONS,
diff --git a/view-station.css b/view-station.css
new file mode 100644
index 0000000..0bc7d83
--- /dev/null
+++ b/view-station.css
@@ -0,0 +1,115 @@
+.IR, .IRN {
+ color: red !important;
+}
+
+.early {
+ color: green !important;
+}
+
+.late {
+ color: red !important;
+}
+
+#-date {
+ display: flex;
+ justify-content: space-between;
+}
+
+#date {
+ text-align: end;
+}
+
+#tabs-arr {
+ border-bottom-color: #55ff55;
+}
+
+#tabs-dep {
+ border-bottom-color: #5555ff;
+}
+
+#arrivals .train-item {
+ background-color: #fafffa;
+}
+
+#arrivals .train-item:nth-of-type(even) {
+ background-color: #eaffea;
+}
+
+#departures .train-item {
+ background-color: #fafaff;
+}
+
+#departures .train-item:nth-of-type(even) {
+ background-color: #eaeaff;
+}
+
+.train-item.cancelled {
+ background-color: #ffeaea !important;
+}
+
+.train-item.cancelled + .train-item.cancelled {
+ border-top: 1px solid black;
+}
+
+.train-item {
+ display: grid;
+ grid-template-columns: 30px 60px auto 1fr auto;
+ grid-template-rows: auto;
+ grid-template-areas:
+ "rank train time terminus platform"
+ "rank train delay terminus platform"
+ "status status status status status";
+ align-items: center;
+ padding: 4px 0;
+
+ page-break-inside: avoid;
+ break-inside: avoid;
+}
+
+.train-item > * {
+ margin: 2px;
+}
+
+.train-item .time {
+ grid-area: time;
+ min-width: 60px;
+ text-align: center;
+}
+
+.train-item .train {
+ grid-area: train;
+}
+
+.train-item .rank {
+ grid-area: rank;
+ white-space: nowrap;
+}
+
+.train-item .delay {
+ grid-area: delay;
+ text-align: center;
+}
+
+.train-item .terminus {
+ grid-area: terminus;
+}
+
+.train-item .status {
+ grid-area: status;
+ text-align: center;
+ margin: 0;
+}
+
+.train-item .platform {
+ grid-area: platform;
+ border: 1px solid black;
+ padding: 1px;
+ margin: 1px;
+ border-radius: 5px;
+ aspect-ratio: 1 / 1;
+ min-width: 22px;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
\ No newline at end of file
diff --git a/view-station.html b/view-station.html
new file mode 100644
index 0000000..7355415
--- /dev/null
+++ b/view-station.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+ View Station - InfoTren
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View Station
+
+
+
+
Arrivals
+ Departures
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view-station.js b/view-station.js
new file mode 100644
index 0000000..c387956
--- /dev/null
+++ b/view-station.js
@@ -0,0 +1,199 @@
+var station
+var date
+
+var stationData = null
+var lastSuccessfulFetch = null
+
+function onStationData(data) {
+ if (!data) {
+ return
+ }
+
+ var title = document.getElementById('title')
+ title.textContent = data.stationName
+
+ document.getElementById('date').textContent = data.date
+
+ /**
+ * @param {HTMLElement} elem
+ * @param {any[]} trains
+ */
+ function addTrains(elem, trains) {
+ while (elem.childNodes.length > 0) {
+ elem.childNodes[0].remove()
+ }
+
+ var trainsList = document.createElement('ul')
+ elem.appendChild(trainsList)
+
+ trains.forEach(function (train, tIdx) {
+ var trainItem = document.createElement('li')
+ trainsList.appendChild(trainItem)
+ trainItem.classList.add('train-item')
+ if (train.status && train.status.cancelled) {
+ trainItem.classList.add('cancelled')
+ }
+
+ var timeDiv = document.createElement('p')
+ trainItem.appendChild(timeDiv)
+ timeDiv.classList.add('pri', 'time')
+ timeDiv.textContent = new Date(train.time).toLocaleTimeString([], { 'hour': '2-digit', 'minute': '2-digit' })
+
+ if (train.status && train.status.delay != 0) {
+ var delayDiv = document.createElement('p')
+ trainItem.appendChild(delayDiv)
+ delayDiv.classList.add('thi', 'delay')
+ delayDiv.textContent = `${train.status.delay} min `
+ // delayDiv.appendChild(document.createElement('br'))
+ var descSpan = document.createElement('span')
+ delayDiv.appendChild(descSpan)
+ if (train.status.delay > 0) {
+ descSpan.classList.add('late')
+ descSpan.textContent = 'late'
+ }
+ else {
+ descSpan.classList.add('early')
+ descSpan.textContent = 'early'
+ }
+ }
+
+ var rankDiv = document.createElement('p')
+ trainItem.appendChild(rankDiv)
+ rankDiv.textContent = train.train.rank
+ rankDiv.classList.add('sec', 'rank', train.train.rank)
+
+ var trainDiv = document.createElement('p')
+ trainItem.appendChild(trainDiv)
+ trainDiv.classList.add('pri', 'train')
+ trainDiv.appendChild(document.createTextNode(`${train.train.number}`))
+
+ var terminusDiv = document.createElement('p')
+ trainItem.appendChild(terminusDiv)
+ terminusDiv.classList.add('pri', 'terminus')
+ terminusDiv.textContent = train.train.terminus
+
+ if (train.status && train.status.platform) {
+ var platformDiv = document.createElement('div')
+ trainItem.appendChild(platformDiv)
+ platformDiv.classList.add('thi', 'platform')
+ platformDiv.textContent = train.status.platform
+ }
+
+ if (train.status && train.status.cancelled) {
+ var statusDiv = document.createElement('p')
+ trainItem.appendChild(statusDiv)
+ statusDiv.classList.add('sec', 'status')
+ statusDiv.textContent = 'This train is cancelled'
+ }
+ })
+ }
+ addTrains(document.getElementById('arrivals'), data.arrivals)
+ addTrains(document.getElementById('departures'), data.departures)
+}
+
+var refreshStopToken = null
+function refresh() {
+ function reschedule(timeout) {
+ if (refreshStopToken != null) {
+ clearTimeout(refreshStopToken)
+ }
+ refreshStopToken = setTimeout(function () {
+ refresh()
+ }, timeout || 90000)
+ }
+ return fetch(
+ `https://scraper.infotren.dcdev.ro/v3/stations/${station}?date=${date.getFullYear().toString()}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`,
+ {
+ cache: 'no-store',
+ },
+ ).then(function (response) {
+ if (!response.ok) {
+ // Check in 10 seconds if server returned error
+ reschedule(10000)
+ return
+ }
+ return response.json()
+ }).then(function (response) {
+ if (!response) {
+ return
+ }
+ stationData = response
+ onStationData(response)
+ reschedule()
+ }).catch(function (e) {
+ // Check in 1 second if network error
+ reschedule(1000)
+ throw e
+ })
+}
+
+window.addEventListener('unload', function (e) {
+ if (refreshStopToken != null) {
+ clearTimeout(refreshStopToken)
+ }
+})
+
+function rsk() {
+ refresh()
+}
+
+window.addEventListener('load', function (e) {
+ if (!new URL(window.location.href).searchParams.has('station')) {
+ window.history.back()
+ this.setTimeout(function () {
+ var url = new URL(window.location.href)
+ url.pathname = 'station.html'
+ window.location.href = url.toString()
+ }, 100)
+ }
+
+ var sp = new URL(window.location.href).searchParams
+
+ station = sp.get('station')
+ date = sp.has('date') ? new Date(sp.get('date')) : new Date()
+
+ // View departures first
+ selectedTab = 1
+ selectTab(selectedTab)
+
+ document.querySelectorAll('.rsk').forEach(function (rskElem) {
+ rskElem.addEventListener('click', function (e) {
+ rsk()
+ })
+ })
+
+ refresh()
+
+ setInterval(function () {
+ if (!lastSuccessfulFetch) {
+ return
+ }
+ var millis = new Date() - lastSuccessfulFetch
+ var secs = Math.floor(millis / 1000)
+
+ var timeStr = ''
+ if (secs / 3600 >= 1) {
+ timeStr += `${Math.floor(secs / 3600)}h`
+ secs = secs % 3600
+ }
+ if (secs / 60 >= 1) {
+ timeStr += `${Math.floor(secs / 60)}m`
+ secs = secs % 60
+ }
+ if (secs >= 1) {
+ timeStr += `${Math.floor(secs)}s`
+ }
+ if (!timeStr) {
+ document.querySelectorAll('.lsk').forEach(function (elem) {
+ elem.textContent = 'Last refreshed now'
+ elem.classList.add('last-refreshed')
+ })
+ }
+ else {
+ document.querySelectorAll('.lsk').forEach(function (elem) {
+ elem.textContent = `Last refreshed ${timeStr} ago`
+ elem.classList.add('last-refreshed')
+ })
+ }
+ }, 500)
+})