diff --git a/package.json b/package.json index 4224cb9..e528335 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "description": "Scrapes Sofia Traffic API and serves it via a local server.", "type": "module", "scripts": { - "start": "node server.mjs" + "start": "node server.mjs", + "test": "node --test" }, "dependencies": { "axios": "1.18.1", diff --git a/src/departures-router.mjs b/src/departures-router.mjs index 60a5d0e..e1ea2db 100644 --- a/src/departures-router.mjs +++ b/src/departures-router.mjs @@ -26,16 +26,19 @@ departuresRouter.get('/departures', (req, res) => { } if (stop && line) { - const minutes = departures - .flatMap((stop) => { - return stop.departures.flatMap((d) => d.next); - }) - .map(Number) - .toSorted((a, b) => a - b); + const pairs = departures.flatMap((stop) => { + return stop.departures.flatMap((d) => + d.next.map((minutes, i) => ({ + minutes: Number(minutes), + time: d.nextTimes?.[i] ?? null, + })) + ); + }); - const uniqueMinutes = [...new Set(minutes)]; + const uniquePairs = [...new Map(pairs.map((p) => [p.minutes, p])).values()] + .toSorted((a, b) => a.minutes - b.minutes); - return res.json(uniqueMinutes); + return res.json(uniquePairs); } return res.json(departures); diff --git a/src/scraper/config.mjs b/src/scraper/config.mjs index 6890d04..c3ad7d6 100644 --- a/src/scraper/config.mjs +++ b/src/scraper/config.mjs @@ -1,6 +1,8 @@ export const ENV_URL = 'https://sofiatraffic.bg/bg/get-envs'; export const VIRTUAL_TABLE_URL = 'https://sofiatraffic.bg/bg/trip/getVirtualTable'; +export const TIMEZONE = 'Europe/Sofia'; + export const TYPES = { 1: 'bus', 2: 'tram', diff --git a/src/scraper/fetch.mjs b/src/scraper/fetch.mjs index cf2e110..84eaa5e 100644 --- a/src/scraper/fetch.mjs +++ b/src/scraper/fetch.mjs @@ -22,7 +22,7 @@ async function fetch(stop, tokens) { } } -async function getStopDepartures(stop, tokens) { +async function getStopDepartures(stop, tokens, scrapeTime) { const data = await fetch(stop, tokens); if (!data) { @@ -30,17 +30,19 @@ async function getStopDepartures(stop, tokens) { } return Object.keys(data).reduce((acc, key) => { - acc.push(transformBusInfo(data[key])); + acc.push(transformBusInfo(data[key], scrapeTime)); return acc; }, []); } export async function getDepartures(stops) { const tokens = await getTokens(); + // One timestamp for the whole batch so concurrently fetched stops agree on "now". + const scrapeTime = Date.now(); const stopPromises = stops.map(async (stop) => ({ stop, - departures: await getStopDepartures(stop, tokens), + departures: await getStopDepartures(stop, tokens, scrapeTime), })); const stopsData = await Promise.all(stopPromises); diff --git a/src/scraper/transform-bus-info.mjs b/src/scraper/transform-bus-info.mjs index 338aa4a..be6e646 100644 --- a/src/scraper/transform-bus-info.mjs +++ b/src/scraper/transform-bus-info.mjs @@ -1,4 +1,20 @@ -import { TYPES } from './config.mjs'; +import { TIMEZONE, TYPES } from './config.mjs'; + +// Explicit timeZone: the Docker container runs UTC, system time can't be trusted. +const timeFormatter = new Intl.DateTimeFormat('en-GB', { + timeZone: TIMEZONE, + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', +}); + +function toClockTime(minutesFromNow, scrapeTime) { + if (!Number.isFinite(minutesFromNow)) { + return null; + } + + return timeFormatter.format(new Date(scrapeTime + minutesFromNow * 60_000)); +} // Example API response data structure // A2454_A99: { @@ -17,12 +33,17 @@ import { TYPES } from './config.mjs'; // last_stop: 'A2454' // }, -export function transformBusInfo(data) { +// next[i] and nextTimes[i] describe the same departure, both derived from details[i].t. +// Times are anchored to scrapeTime, not request time — the file may be read up to a minute later. +export function transformBusInfo(data, scrapeTime = Date.now()) { + const minutes = data.details.map((d) => d.t); + return { type: TYPES[data.type], name: data.name, destination: data.st_name, destination_stop: data.st_code, - next: data.details.map((d) => d.t), + next: minutes, + nextTimes: minutes.map((m) => toClockTime(m, scrapeTime)), }; } diff --git a/tests/transform-bus-info.test.mjs b/tests/transform-bus-info.test.mjs new file mode 100644 index 0000000..d41be51 --- /dev/null +++ b/tests/transform-bus-info.test.mjs @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { transformBusInfo } from '../src/scraper/transform-bus-info.mjs'; + +function busInfo(details) { + return { + type: 1, + name: '84', + st_name: 'ЛЕТИЩЕ СОФИЯ ТЕРМИНАЛ 2', + st_code: '2454', + details, + }; +} + +test('formats times in Europe/Sofia regardless of system timezone (summer, UTC+3)', () => { + const scrapeTime = Date.parse('2026-07-10T10:00:00Z'); // 13:00 in Sofia + + const result = transformBusInfo(busInfo([{ t: 0 }, { t: 5 }]), scrapeTime); + + assert.deepEqual(result.next, [0, 5]); + assert.deepEqual(result.nextTimes, ['13:00', '13:05']); +}); + +test('winter time uses UTC+2', () => { + const scrapeTime = Date.parse('2026-01-15T10:00:00Z'); // 12:00 in Sofia + + const result = transformBusInfo(busInfo([{ t: 30 }]), scrapeTime); + + assert.deepEqual(result.nextTimes, ['12:30']); +}); + +test('rolls over midnight via date arithmetic', () => { + const scrapeTime = Date.parse('2026-07-10T20:55:00Z'); // 23:55 in Sofia + + const result = transformBusInfo(busInfo([{ t: 10 }]), scrapeTime); + + assert.deepEqual(result.nextTimes, ['00:05']); +}); + +test('pads single-digit hours and minutes, no AM/PM', () => { + const scrapeTime = Date.parse('2026-07-10T06:03:00Z'); // 09:03 in Sofia + + const result = transformBusInfo(busInfo([{ t: 0 }]), scrapeTime); + + assert.deepEqual(result.nextTimes, ['09:03']); +}); + +test('empty details produce empty arrays', () => { + const result = transformBusInfo(busInfo([]), Date.now()); + + assert.deepEqual(result.next, []); + assert.deepEqual(result.nextTimes, []); +}); + +test('non-numeric t yields null time and keeps arrays index-aligned', () => { + const scrapeTime = Date.parse('2026-07-10T10:00:00Z'); + + const result = transformBusInfo(busInfo([{ t: 2 }, { t: null }, { t: 10 }]), scrapeTime); + + assert.deepEqual(result.next, [2, null, 10]); + assert.deepEqual(result.nextTimes, ['13:02', null, '13:10']); + assert.equal(result.next.length, result.nextTimes.length); +});