From 93b266f26d239d9a72d896186eaeee6faca1eebd Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Fri, 10 Jul 2026 23:23:36 +0300 Subject: [PATCH] Add local time handling for ETA calculations and tests --- backend/src/controllers/etaController.js | 55 ++++++++++++++---------- backend/src/utils/etaTime.js | 46 ++++++++++++++++++++ backend/test/etaTimezone.test.js | 16 +++++++ 3 files changed, 94 insertions(+), 23 deletions(-) create mode 100644 backend/src/utils/etaTime.js create mode 100644 backend/test/etaTimezone.test.js diff --git a/backend/src/controllers/etaController.js b/backend/src/controllers/etaController.js index edb933c..51463fd 100644 --- a/backend/src/controllers/etaController.js +++ b/backend/src/controllers/etaController.js @@ -1,11 +1,13 @@ const pool = require('../config/database'); const logger = require('../config/logger'); +const { getLocalDateTime, getIsoWeekday } = require('../utils/etaTime'); class EtaController { // Calculate ETA for a stop on a route async calculateEta(req, res) { const { stopId, routeId } = req.body; const currentTime = new Date(); + const localNow = this.getLocalDateTime(currentTime); if (!stopId || !routeId) { return res.status(400).json({ @@ -17,7 +19,8 @@ class EtaController { try { // Get schedule type for today (holiday-aware) const typeResult = await pool.query( - 'SELECT get_schedule_type(CURRENT_DATE) as schedule_type' + 'SELECT get_schedule_type($1::date) as schedule_type', + [localNow.date] ); let scheduleType = typeResult.rows[0].schedule_type; @@ -27,10 +30,10 @@ class EtaController { FROM schedules WHERE route_id = $1 AND day_type = $2 - AND (valid_from IS NULL OR valid_from <= CURRENT_DATE) - AND (valid_until IS NULL OR valid_until >= CURRENT_DATE) + AND (valid_from IS NULL OR valid_from <= $3::date) + AND (valid_until IS NULL OR valid_until >= $3::date) LIMIT 1 - `, [routeId, scheduleType]); + `, [routeId, scheduleType, localNow.date]); // If holiday and no specific holiday schedule, fall back to sunday if (scheduleResult.rows.length === 0 && scheduleType === 'holiday') { @@ -40,10 +43,10 @@ class EtaController { FROM schedules WHERE route_id = $1 AND day_type = 'sunday' - AND (valid_from IS NULL OR valid_from <= CURRENT_DATE) - AND (valid_until IS NULL OR valid_until >= CURRENT_DATE) + AND (valid_from IS NULL OR valid_from <= $2::date) + AND (valid_until IS NULL OR valid_until >= $2::date) LIMIT 1 - `, [routeId]); + `, [routeId, localNow.date]); } if (scheduleResult.rows.length === 0) { @@ -80,7 +83,7 @@ class EtaController { )); // Calculate ETAs - const currentTimeStr = currentTime.toTimeString().split(' ')[0]; + const currentTimeStr = localNow.time; const etas = []; for (const trip of activeTrips) { @@ -90,7 +93,7 @@ class EtaController { const delay = await this.getHistoricalDelay( routeId, stopId, - currentTime.getHours(), + localNow.hour, scheduleType === 'holiday' ? 'sunday' : scheduleType ); @@ -175,11 +178,13 @@ class EtaController { // Helper: Calculate ETA for a specific route (internal use) async calculateEtaForRoute(stopId, routeId) { const currentTime = new Date(); + const localNow = this.getLocalDateTime(currentTime); let dayType = this.getDayType(currentTime); try { const typeResult = await pool.query( - 'SELECT get_schedule_type(CURRENT_DATE) as schedule_type' + 'SELECT get_schedule_type($1::date) as schedule_type', + [localNow.date] ); dayType = typeResult.rows[0].schedule_type; } catch (error) { @@ -190,10 +195,10 @@ class EtaController { SELECT departure_times, trips FROM schedules WHERE route_id = $1 AND day_type = $2 - AND (valid_from IS NULL OR valid_from <= CURRENT_DATE) - AND (valid_until IS NULL OR valid_until >= CURRENT_DATE) + AND (valid_from IS NULL OR valid_from <= $3::date) + AND (valid_until IS NULL OR valid_until >= $3::date) LIMIT 1 - `, [routeId, dayType]); + `, [routeId, dayType, localNow.date]); if (scheduleResult.rows.length === 0 && dayType === 'holiday') { dayType = 'sunday'; @@ -201,10 +206,10 @@ class EtaController { SELECT departure_times, trips FROM schedules WHERE route_id = $1 AND day_type = 'sunday' - AND (valid_from IS NULL OR valid_from <= CURRENT_DATE) - AND (valid_until IS NULL OR valid_until >= CURRENT_DATE) + AND (valid_from IS NULL OR valid_from <= $2::date) + AND (valid_until IS NULL OR valid_until >= $2::date) LIMIT 1 - `, [routeId]); + `, [routeId, localNow.date]); } if (scheduleResult.rows.length === 0) return []; @@ -227,7 +232,7 @@ class EtaController { this.tripServesStop(trip, targetStop, stopsById) )); - const currentTimeStr = currentTime.toTimeString().split(' ')[0]; + const currentTimeStr = localNow.time; const etas = []; for (const trip of activeTrips) { @@ -237,7 +242,7 @@ class EtaController { const delay = await this.getHistoricalDelay( routeId, stopId, - currentTime.getHours(), + localNow.hour, dayType ); @@ -278,12 +283,17 @@ class EtaController { // Helper: Determine day type getDayType(date) { - const day = date.getDay(); - if (day === 0) return 'sunday'; - if (day === 6) return 'saturday'; + const isoWeekday = this.getIsoWeekday(date); + if (isoWeekday === 7) return 'sunday'; + if (isoWeekday === 6) return 'saturday'; return 'weekday'; } + // All schedule and ETA comparisons use Polotsk local civil time. + getLocalDateTime(date = new Date()) { + return getLocalDateTime(date); + } + // Helper: Add minutes to time string addMinutesToTime(timeStr, minutes) { const [hours, mins, secs] = timeStr.split(':').map(Number); @@ -380,8 +390,7 @@ class EtaController { } getIsoWeekday(date) { - const day = date.getDay(); - return day === 0 ? 7 : day; + return getIsoWeekday(date); } publicTripInfo(trip) { diff --git a/backend/src/utils/etaTime.js b/backend/src/utils/etaTime.js new file mode 100644 index 0000000..334b9a6 --- /dev/null +++ b/backend/src/utils/etaTime.js @@ -0,0 +1,46 @@ +const ETA_TIME_ZONE = 'Europe/Minsk'; + +function getLocalDateTime(date = new Date()) { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: ETA_TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23' + }).formatToParts(date).reduce((result, part) => { + if (part.type !== 'literal') result[part.type] = part.value; + return result; + }, {}); + + return { + date: `${parts.year}-${parts.month}-${parts.day}`, + time: `${parts.hour}:${parts.minute}:${parts.second}`, + hour: Number(parts.hour) + }; +} + +function getIsoWeekday(date = new Date()) { + const weekday = new Intl.DateTimeFormat('en-US', { + timeZone: ETA_TIME_ZONE, + weekday: 'short' + }).format(date); + + return { + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + Sun: 7 + }[weekday]; +} + +module.exports = { + ETA_TIME_ZONE, + getIsoWeekday, + getLocalDateTime +}; diff --git a/backend/test/etaTimezone.test.js b/backend/test/etaTimezone.test.js new file mode 100644 index 0000000..290d11c --- /dev/null +++ b/backend/test/etaTimezone.test.js @@ -0,0 +1,16 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { ETA_TIME_ZONE, getIsoWeekday, getLocalDateTime } = require('../src/utils/etaTime'); + +test('ETA uses Europe/Minsk local date and time instead of UTC', () => { + const utcDate = new Date('2026-07-10T21:30:15.000Z'); + + assert.equal(ETA_TIME_ZONE, 'Europe/Minsk'); + assert.deepEqual(getLocalDateTime(utcDate), { + date: '2026-07-11', + time: '00:30:15', + hour: 0 + }); + assert.equal(getIsoWeekday(utcDate), 6); +});