diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index e2e90be05..f4e67656d 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -11,6 +11,7 @@ import { useEffect, useState } from "react"; import { CheckCircle, Clock, Copy, Train, FileText } from "lucide-react"; import { format } from "date-fns"; import { isChild, isFirstChild } from "@/utils/fare-utils"; +import { toZonedDate } from "@/utils/format"; type BookingWithTicket = { id: string; @@ -383,7 +384,7 @@ export default function ConfirmationPage() {

{schedule.departureTime && - format(new Date(schedule.departureTime), "PPp")} + format(toZonedDate(new Date(schedule.departureTime)), "PPp")}

@@ -392,7 +393,7 @@ export default function ConfirmationPage() {

{schedule.arrivalTime && - format(new Date(schedule.arrivalTime), "PPp")} + format(toZonedDate(new Date(schedule.arrivalTime)), "PPp")}

@@ -456,7 +457,7 @@ export default function ConfirmationPage() {

{inboundSchedule.departureTime && format( - new Date(inboundSchedule.departureTime), + toZonedDate(new Date(inboundSchedule.departureTime)), "PPp", )}

@@ -468,7 +469,7 @@ export default function ConfirmationPage() {

{inboundSchedule.arrivalTime && format( - new Date(inboundSchedule.arrivalTime), + toZonedDate(new Date(inboundSchedule.arrivalTime)), "PPp", )}

diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index 6442fd0d6..addaf685d 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -21,7 +21,7 @@ import { X, } from "lucide-react"; import { format } from "date-fns"; -import { formatTime, getTimePeriod } from "@/utils/format"; +import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format"; import { formatFare } from "@/utils/fare-utils"; import { markManageBookingPaymentReturn, @@ -606,7 +606,7 @@ function BookingDetailContent() {
{booking.schedule?.departureAt - ? `${format(new Date(booking.schedule.departureAt), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.departureAt)}` + ? `${format(toZonedDate(new Date(booking.schedule.departureAt)), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.departureAt)}` : "N/A"}
@@ -655,7 +655,7 @@ function BookingDetailContent() {
{booking.schedule?.arrivalAt - ? `${format(new Date(booking.schedule.arrivalAt), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.arrivalAt)}` + ? `${format(toZonedDate(new Date(booking.schedule.arrivalAt)), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.arrivalAt)}` : "N/A"}
@@ -1014,7 +1014,7 @@ function BookingDetailContent() {
{booking.schedule?.departureAt - ? `${format(new Date(booking.schedule.departureAt), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.departureAt)}` + ? `${format(toZonedDate(new Date(booking.schedule.departureAt)), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.departureAt)}` : "N/A"}
@@ -1063,7 +1063,7 @@ function BookingDetailContent() {
{booking.schedule?.arrivalAt - ? `${format(new Date(booking.schedule.arrivalAt), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.arrivalAt)}` + ? `${format(toZonedDate(new Date(booking.schedule.arrivalAt)), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.arrivalAt)}` : "N/A"}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx index da369fb54..3f371280f 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { apiClient } from "@/lib/api-client"; import { format } from "date-fns"; +import { toZonedDate } from "@/utils/format"; type SearchMode = "pnr" | "phone"; @@ -220,7 +221,7 @@ export default function BookingLookupPage() { {b.schedule.originStation.name} → {b.schedule.destinationStation.name}

- {format(new Date(b.schedule.departureAt), "dd MMM yyyy, HH:mm")} + {format(toZonedDate(new Date(b.schedule.departureAt)), "dd MMM yyyy, HH:mm")} {" · "} {b.adultCount} adult{b.adultCount !== 1 ? "s" : ""} {b.childCount > 0 && `, ${b.childCount} child${b.childCount !== 1 ? "ren" : ""}`} diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 3992e140f..237f8086e 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -8,7 +8,7 @@ import { apiClient } from "@/lib/api-client"; import { useState, useEffect } from "react"; import { PaymentMethod } from "@/types"; import { format } from "date-fns"; -import { formatTime, getTimePeriod } from '@/utils/format'; +import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format'; import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils'; import { CreditCard, @@ -289,7 +289,7 @@ export default function PaymentPage() {

{schedule?.departureTime - ? `${format(new Date(schedule.departureTime), 'EEE, MMM d')} · ${getTimePeriod(schedule.departureTime)}` + ? `${format(toZonedDate(new Date(schedule.departureTime)), 'EEE, MMM d')} · ${getTimePeriod(schedule.departureTime)}` : ''}
{schedule?.origin}
@@ -304,7 +304,7 @@ export default function PaymentPage() {
{schedule?.arrivalTime - ? `${format(new Date(schedule.arrivalTime), 'EEE, MMM d')} · ${getTimePeriod(schedule.arrivalTime)}` + ? `${format(toZonedDate(new Date(schedule.arrivalTime)), 'EEE, MMM d')} · ${getTimePeriod(schedule.arrivalTime)}` : ''}
{schedule?.destination}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index afaeb1f44..fa31d1a7c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -21,7 +21,7 @@ import { Star, } from "lucide-react"; import { format } from "date-fns"; -import { formatTime, getTimePeriod } from "@/utils/format"; +import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format"; import { formatFare } from "@/utils/fare-utils"; import { useCurrencySymbol } from "@/lib/useCurrencies"; import { useState, useEffect } from "react"; @@ -657,7 +657,7 @@ export default function ResultsPage() { Different date —{" "} {schedule.departureAt - ? format(new Date(schedule.departureAt), "EEEE, MMMM d, yyyy") + ? format(toZonedDate(new Date(schedule.departureAt)), "EEEE, MMMM d, yyyy") : "N/A"}
{schedule.departureAt - ? `${format(new Date(schedule.departureAt), "MMM d")} · ${getTimePeriod(schedule.departureAt)}` + ? `${format(toZonedDate(new Date(schedule.departureAt)), "MMM d")} · ${getTimePeriod(schedule.departureAt)}` : ""}
@@ -730,7 +730,7 @@ export default function ResultsPage() {
{schedule.arrivalAt - ? `${format(new Date(schedule.arrivalAt), "MMM d")} · ${getTimePeriod(schedule.arrivalAt)}` + ? `${format(toZonedDate(new Date(schedule.arrivalAt)), "MMM d")} · ${getTimePeriod(schedule.arrivalAt)}` : ""} {isNextDay && ( @@ -1213,7 +1213,7 @@ export default function ResultsPage() { {outboundScheduleData.origin} →{" "} {outboundScheduleData.destination} {outboundScheduleData.departureTime - ? ` · ${format(new Date(outboundScheduleData.departureTime), "EEE, MMM d, yyyy")}` + ? ` · ${format(toZonedDate(new Date(outboundScheduleData.departureTime)), "EEE, MMM d, yyyy")}` : ""} {outboundScheduleData.selectedSeatClassName ? ` · ${outboundScheduleData.selectedSeatClassName}` diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 265417d58..237d689f1 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -6,7 +6,7 @@ import { useAuthStore } from '@/lib/auth-store'; import { useMutation } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { format } from 'date-fns'; -import { formatTime, getTimePeriod } from '@/utils/format'; +import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format'; import { useState, useEffect, useCallback } from 'react'; import { ChevronLeft } from 'lucide-react'; import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils'; @@ -690,7 +690,7 @@ export default function ReviewPage() {
{outboundSchedule.departureTime - ? `${format(new Date(outboundSchedule.departureTime), 'EEE, MMM d')} · ${getTimePeriod(outboundSchedule.departureTime)}` + ? `${format(toZonedDate(new Date(outboundSchedule.departureTime)), 'EEE, MMM d')} · ${getTimePeriod(outboundSchedule.departureTime)}` : 'N/A'}
@@ -723,7 +723,7 @@ export default function ReviewPage() {
{outboundSchedule.arrivalTime - ? `${format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d')} · ${getTimePeriod(outboundSchedule.arrivalTime)}` + ? `${format(toZonedDate(new Date(outboundSchedule.arrivalTime)), 'EEE, MMM d')} · ${getTimePeriod(outboundSchedule.arrivalTime)}` : 'N/A'}
@@ -764,7 +764,7 @@ export default function ReviewPage() {
{inboundSchedule.departureTime - ? `${format(new Date(inboundSchedule.departureTime), 'EEE, MMM d')} · ${getTimePeriod(inboundSchedule.departureTime)}` + ? `${format(toZonedDate(new Date(inboundSchedule.departureTime)), 'EEE, MMM d')} · ${getTimePeriod(inboundSchedule.departureTime)}` : 'N/A'}
@@ -797,7 +797,7 @@ export default function ReviewPage() {
{inboundSchedule.arrivalTime - ? `${format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d')} · ${getTimePeriod(inboundSchedule.arrivalTime)}` + ? `${format(toZonedDate(new Date(inboundSchedule.arrivalTime)), 'EEE, MMM d')} · ${getTimePeriod(inboundSchedule.arrivalTime)}` : 'N/A'}
@@ -841,7 +841,7 @@ export default function ReviewPage() {
{selectedSchedule.departureTime - ? `${format(new Date(selectedSchedule.departureTime), 'EEE, MMM d')} · ${getTimePeriod(selectedSchedule.departureTime)}` + ? `${format(toZonedDate(new Date(selectedSchedule.departureTime)), 'EEE, MMM d')} · ${getTimePeriod(selectedSchedule.departureTime)}` : 'N/A'}
@@ -874,7 +874,7 @@ export default function ReviewPage() {
{selectedSchedule.arrivalTime - ? `${format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d')} · ${getTimePeriod(selectedSchedule.arrivalTime)}` + ? `${format(toZonedDate(new Date(selectedSchedule.arrivalTime)), 'EEE, MMM d')} · ${getTimePeriod(selectedSchedule.arrivalTime)}` : 'N/A'}
diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index bb839906d..067d93a1b 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -105,17 +105,22 @@ interface PackageDetail { // ─── Helpers ────────────────────────────────────────────────────────────────── +// Train schedules are stored/computed server-side in Ethiopian time (EAT - UTC+3) — +// pinning display to the same zone keeps this page consistent with every other +// booking page and the voucher PDF, regardless of the viewing device's own timezone. function fmt(iso: string, opts?: Intl.DateTimeFormatOptions): string { try { - return new Date(iso).toLocaleDateString("en-US", opts ?? { + return new Date(iso).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", + ...opts, + timeZone: "Africa/Addis_Ababa", }); } catch { return iso; } } function fmtTime(iso: string): string { try { - return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true }); + return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true, timeZone: "Africa/Addis_Ababa" }); } catch { return iso; } } diff --git a/apps/edr-passenger-web/portal/src/app/profile/page.tsx b/apps/edr-passenger-web/portal/src/app/profile/page.tsx index d44474976..37ffdabfb 100644 --- a/apps/edr-passenger-web/portal/src/app/profile/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/profile/page.tsx @@ -346,8 +346,8 @@ export default function ProfilePage() {
- {booking.trip?.departureAt - ? new Date(booking.trip.departureAt).toLocaleDateString() + {booking.trip?.departureAt + ? new Date(booking.trip.departureAt).toLocaleDateString('en-US', { timeZone: 'Africa/Addis_Ababa' }) : 'N/A'}
diff --git a/apps/edr-passenger-web/portal/src/components/PackagesSection.tsx b/apps/edr-passenger-web/portal/src/components/PackagesSection.tsx index 304671050..adfc838ea 100644 --- a/apps/edr-passenger-web/portal/src/components/PackagesSection.tsx +++ b/apps/edr-passenger-web/portal/src/components/PackagesSection.tsx @@ -59,12 +59,16 @@ interface HolidayPackage { // ─── Helpers ────────────────────────────────────────────────────────────────── +// Pinned to Ethiopian time (EAT - UTC+3) — the zone train schedules are stored/computed +// in server-side — so this matches every other booking page and the voucher PDF +// regardless of the viewing device's own timezone. function fmtDate(iso: string, opts?: Intl.DateTimeFormatOptions): string { try { - return new Date(iso).toLocaleDateString( - "en-US", - opts ?? { month: "short", day: "numeric" }, - ); + return new Date(iso).toLocaleDateString("en-US", { + month: "short", day: "numeric", + ...opts, + timeZone: "Africa/Addis_Ababa", + }); } catch { return iso; } diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index a25679d75..47ee3fdcc 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -1,6 +1,12 @@ import jsPDF from 'jspdf'; import QRCode from 'qrcode'; +// Train schedules are stored/computed server-side in Ethiopian time (EAT - UTC+3) — see +// apps/edr-passenger-api's timezone.utils.ts. Pinning every date/time on the voucher to +// this same zone (instead of the generating device's own local zone) is what keeps the +// PDF's journey times matching what the booking pages show, regardless of the device. +const APP_TIMEZONE = 'Africa/Addis_Ababa'; + interface ScheduleInfo { trainNumber: string; trainName?: string; @@ -255,9 +261,9 @@ function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, legLabel: string | n const dep = new Date(schedule.departureAt); doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold'); - doc.text(dep.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), margin + padX, topY + 18); + doc.text(dep.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, timeZone: APP_TIMEZONE }), margin + padX, topY + 18); doc.setTextColor(...MUTED); doc.setFontSize(7); doc.setFont('helvetica', 'normal'); - doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }), margin + padX, topY + 22); + doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', timeZone: APP_TIMEZONE }), margin + padX, topY + 22); // Destination block (right-aligned) const dx = pageWidth - margin - padX; @@ -269,9 +275,9 @@ function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, legLabel: string | n const arr = new Date(schedule.arrivalAt); doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold'); - doc.text(arr.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), dx, topY + 18, { align: 'right' }); + doc.text(arr.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, timeZone: APP_TIMEZONE }), dx, topY + 18, { align: 'right' }); doc.setTextColor(...MUTED); doc.setFontSize(7); doc.setFont('helvetica', 'normal'); - doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }), dx, topY + 22, { align: 'right' }); + doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', timeZone: APP_TIMEZONE }), dx, topY + 22, { align: 'right' }); // Dashed route line with endpoint markers, connecting the two blocks const lineY = topY + 7.5; @@ -387,7 +393,7 @@ function drawFooter(doc: jsPDF, createdAt: string, contentY: number): void { doc.setFontSize(7.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal'); doc.text('edr_@edrsc.com · 9546 · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' }); doc.setFontSize(6.5); - doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 10.5, { align: 'center' }); + doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US', { timeZone: APP_TIMEZONE })}`, pageWidth / 2, footerY + 10.5, { align: 'center' }); } // ─── public API ────────────────────────────────────────────────────────────── diff --git a/apps/edr-passenger-web/portal/src/utils/format.ts b/apps/edr-passenger-web/portal/src/utils/format.ts index 3743334ad..9f5bb9836 100644 --- a/apps/edr-passenger-web/portal/src/utils/format.ts +++ b/apps/edr-passenger-web/portal/src/utils/format.ts @@ -1,5 +1,24 @@ import { format } from 'date-fns'; +// The backend stores and computes every train schedule in Ethiopian time (EAT - +// UTC+3) — see apps/edr-passenger-api's timezone.utils.ts and main.ts, which pin the +// server process to this zone. The frontend previously formatted dates using +// whatever timezone the viewing/generating device happened to be set to, which made +// the on-screen booking pages and the downloaded voucher PDF disagree (by exactly +// the device's offset from EAT) whenever that device wasn't itself in EAT. Pinning +// every display here to the same explicit zone makes them agree by construction. +const APP_TIMEZONE = 'Africa/Addis_Ababa'; + +// date-fns' `format()` always reads the Date object's LOCAL (device) getters — it has +// no built-in timezone conversion. This shifts the Date so those local getters report +// the wall-clock time in `timeZone` instead of the device's own zone, so `format()` +// calls (here and at other call sites formatting journey times) produce EAT-correct +// output regardless of the device. Exported for pages that call date-fns' `format()` +// directly instead of going through the helpers below. +export function toZonedDate(date: Date, timeZone: string = APP_TIMEZONE): Date { + return new Date(date.toLocaleString('en-US', { timeZone })); +} + export const formatCurrency = (amount: number, currency: string = 'ETB'): string => { return new Intl.NumberFormat('en-US', { style: 'currency', @@ -9,19 +28,19 @@ export const formatCurrency = (amount: number, currency: string = 'ETB'): string }; export const formatDate = (date: string | Date, formatStr: string = 'MMM dd, yyyy'): string => { - return format(new Date(date), formatStr); + return format(toZonedDate(new Date(date), APP_TIMEZONE), formatStr); }; export const formatDateTime = (date: string | Date): string => { - return format(new Date(date), 'MMM dd, yyyy h:mm a'); + return format(toZonedDate(new Date(date), APP_TIMEZONE), 'MMM dd, yyyy h:mm a'); }; export const formatTime = (date: string | Date): string => { - return format(new Date(date), 'h:mm a'); + return format(toZonedDate(new Date(date), APP_TIMEZONE), 'h:mm a'); }; export const getTimePeriod = (date: string | Date): string => { - const hour = new Date(date).getHours(); + const hour = toZonedDate(new Date(date), APP_TIMEZONE).getHours(); if (hour >= 5 && hour < 12) return 'Morning'; if (hour >= 12 && hour < 17) return 'Afternoon'; if (hour >= 17 && hour < 21) return 'Evening';