Fix datetime format

This commit is contained in:
Roba Boru
2026-07-14 14:37:57 +03:00
parent cee06775e1
commit ec519e128e
11 changed files with 78 additions and 42 deletions

View File

@@ -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() {
</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{schedule.departureTime &&
format(new Date(schedule.departureTime), "PPp")}
format(toZonedDate(new Date(schedule.departureTime)), "PPp")}
</p>
</div>
<div>
@@ -392,7 +393,7 @@ export default function ConfirmationPage() {
</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{schedule.arrivalTime &&
format(new Date(schedule.arrivalTime), "PPp")}
format(toZonedDate(new Date(schedule.arrivalTime)), "PPp")}
</p>
</div>
<div>
@@ -456,7 +457,7 @@ export default function ConfirmationPage() {
<p className="font-semibold text-gray-900 dark:text-gray-100">
{inboundSchedule.departureTime &&
format(
new Date(inboundSchedule.departureTime),
toZonedDate(new Date(inboundSchedule.departureTime)),
"PPp",
)}
</p>
@@ -468,7 +469,7 @@ export default function ConfirmationPage() {
<p className="font-semibold text-gray-900 dark:text-gray-100">
{inboundSchedule.arrivalTime &&
format(
new Date(inboundSchedule.arrivalTime),
toZonedDate(new Date(inboundSchedule.arrivalTime)),
"PPp",
)}
</p>

View File

@@ -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() {
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{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"}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
@@ -655,7 +655,7 @@ function BookingDetailContent() {
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{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"}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
@@ -1014,7 +1014,7 @@ function BookingDetailContent() {
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{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"}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
@@ -1063,7 +1063,7 @@ function BookingDetailContent() {
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{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"}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">

View File

@@ -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}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{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" : ""}`}

View File

@@ -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() {
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{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)}`
: ''}
</div>
<div className="text-sm font-semibold text-gray-900 dark:text-white mt-1">{schedule?.origin}</div>
@@ -304,7 +304,7 @@ export default function PaymentPage() {
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{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)}`
: ''}
</div>
<div className="text-sm font-semibold text-gray-900 dark:text-white mt-1">{schedule?.destination}</div>

View File

@@ -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() {
<Calendar className="w-3.5 h-3.5" />
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"}
</span>
<span
@@ -696,7 +696,7 @@ export default function ResultsPage() {
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{schedule.departureAt
? `${format(new Date(schedule.departureAt), "MMM d")} · ${getTimePeriod(schedule.departureAt)}`
? `${format(toZonedDate(new Date(schedule.departureAt)), "MMM d")} · ${getTimePeriod(schedule.departureAt)}`
: ""}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
@@ -730,7 +730,7 @@ export default function ResultsPage() {
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1">
<span>
{schedule.arrivalAt
? `${format(new Date(schedule.arrivalAt), "MMM d")} · ${getTimePeriod(schedule.arrivalAt)}`
? `${format(toZonedDate(new Date(schedule.arrivalAt)), "MMM d")} · ${getTimePeriod(schedule.arrivalAt)}`
: ""}
</span>
{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}`

View File

@@ -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() {
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{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'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
@@ -723,7 +723,7 @@ export default function ReviewPage() {
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{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'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
@@ -764,7 +764,7 @@ export default function ReviewPage() {
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{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'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
@@ -797,7 +797,7 @@ export default function ReviewPage() {
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{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'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
@@ -841,7 +841,7 @@ export default function ReviewPage() {
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{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'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
@@ -874,7 +874,7 @@ export default function ReviewPage() {
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{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'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">

View File

@@ -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; }
}

View File

@@ -346,8 +346,8 @@ export default function ProfilePage() {
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-400" />
<span className="text-gray-600 dark:text-gray-400">
{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'}
</span>
</div>

View File

@@ -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;
}

View File

@@ -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 ──────────────────────────────────────────────────────────────

View File

@@ -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';