diff --git a/frontend/src/__tests__/events.test.ts b/frontend/src/__tests__/events.test.ts index 97e72d4..8561204 100644 --- a/frontend/src/__tests__/events.test.ts +++ b/frontend/src/__tests__/events.test.ts @@ -25,6 +25,26 @@ import { } from "@/services/events"; describe("ledgerClosedAtToUnixSeconds", () => { + const expected = Date.UTC(2026, 1, 26, 15, 4) / 1000; + + it.each([ + ["ISO string", "2026-02-26T15:04:00.000Z"], + ["numeric milliseconds", Date.UTC(2026, 1, 26, 15, 4)], + ["Date", new Date("2026-02-26T15:04:00.000Z")], + ])("accepts a valid %s", (_label, value) => { + expect(ledgerClosedAtToUnixSeconds(value)).toBe(expected); + }); + + it.each([ + ["invalid string", "not-a-date"], + ["missing value", undefined], + ["NaN", Number.NaN], + ["positive infinity", Number.POSITIVE_INFINITY], + ["negative infinity", Number.NEGATIVE_INFINITY], + ])("throws RangeError for %s", (_label, value) => { + expect(() => + ledgerClosedAtToUnixSeconds(value as unknown as string) + ).toThrow(new RangeError("Invalid ledger close timestamp")); const expectedSeconds = Date.UTC(2026, 6, 18, 8, 30) / 1000; it.each([ @@ -53,6 +73,16 @@ describe("ledgerClosedAtToUnixSeconds", () => { }); describe("pollMarketEvents", () => { + it("discards an event with malformed ledgerClosedAt", async () => { + const malformedEvent = { + topic: ["market_cancelled", 7], + value: {}, + ledgerClosedAt: "not-a-date", + txHash: "malformed-event", + }; + + mocks.getLatestLedger.mockResolvedValue({ sequence: 100 }); + mocks.getEvents.mockResolvedValue({ events: [malformedEvent] }); it("drops an event with a malformed close timestamp", async () => { mocks.getLatestLedger.mockResolvedValue({ sequence: 100 }); mocks.getEvents.mockResolvedValue({ diff --git a/frontend/src/__tests__/helpers.test.ts b/frontend/src/__tests__/helpers.test.ts index a90e246..1180cc6 100644 --- a/frontend/src/__tests__/helpers.test.ts +++ b/frontend/src/__tests__/helpers.test.ts @@ -35,6 +35,8 @@ import { formatXLM, truncateAddress, isValidAmount, + formatDate, + formatTime, timeUntil, formatDate, formatTime, @@ -46,6 +48,22 @@ import { timeAgo, } from "@/utils/helpers"; +describe("localized timestamp formatters", () => { + const timestamp = Date.UTC(2026, 2, 1, 1, 30) / 1000; + + it.each([ + ["en-US", "UTC", "Mar 1, 2026, 01:30 AM", "01:30 AM"], + ["en-US", "America/New_York", "Feb 28, 2026, 08:30 PM", "08:30 PM"], + ["en-US", "Asia/Ho_Chi_Minh", "Mar 1, 2026, 08:30 AM", "08:30 AM"], + ["vi-VN", "UTC", "01:30 1 thg 3, 2026", "01:30"], + ["vi-VN", "America/New_York", "20:30 28 thg 2, 2026", "20:30"], + ["vi-VN", "Asia/Ho_Chi_Minh", "08:30 1 thg 3, 2026", "08:30"], + ])("formats %s in %s independently of the test machine", (locale, timeZone, date, time) => { + expect(formatDate(timestamp, locale, timeZone)).toBe(date); + expect(formatTime(timestamp, locale, timeZone)).toBe(time); + }); +}); + // ── formatXLM ───────────────────────────────────────────────────────────────── describe("formatXLM", () => { diff --git a/frontend/src/app/markets/[id]/page.tsx b/frontend/src/app/markets/[id]/page.tsx index 4874ccb..8e5aa29 100644 --- a/frontend/src/app/markets/[id]/page.tsx +++ b/frontend/src/app/markets/[id]/page.tsx @@ -7,6 +7,7 @@ import { useWallet } from "@/hooks/useWallet"; import { useToken } from "@/hooks/useToken"; import { pollMarketEvents } from "@/services/events"; import { getXlmBalance } from "@/services/soroban"; +import { displayXLM, formatTime, formatXLM, calculatePayout, truncateAddress } from "@/utils/helpers"; import { WIN_POINTS, LOSE_POINTS, diff --git a/frontend/src/services/events.ts b/frontend/src/services/events.ts index 4db3cd1..8683bdf 100644 --- a/frontend/src/services/events.ts +++ b/frontend/src/services/events.ts @@ -19,6 +19,20 @@ function isKnownEventType(s: string): s is ContractEventType { return (EVENT_TYPES as readonly string[]).includes(s); } +/** Convert the ledger close time to the app's Unix-seconds timestamp contract. */ +export function ledgerClosedAtToUnixSeconds( + ledgerClosedAt: string | number | Date +): number { + const timestampMs = + ledgerClosedAt instanceof Date + ? ledgerClosedAt.getTime() + : new Date(ledgerClosedAt).getTime(); + + if (!Number.isFinite(timestampMs)) { + throw new RangeError("Invalid ledger close timestamp"); + } + + return Math.floor(timestampMs / 1000); const MILLISECOND_TIMESTAMP_THRESHOLD = 100_000_000_000; const ISO_TIMESTAMP_WITH_TIME_ZONE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:[zZ]|[+-](\d{2}):(\d{2}))$/; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 670a979..224ac37 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -129,6 +129,7 @@ export interface MarketEvent { user: string; marketId: number; amount?: number; + /** Unix timestamp in seconds. */ timestamp: number; txHash: string; } diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index 00be0f0..e4bebf8 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -53,6 +53,41 @@ export function timeUntil(timestamp: number): string { return `${diff}s`; } +const DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", +}; + +const TIME_OPTIONS: Intl.DateTimeFormatOptions = { + hour: "2-digit", + minute: "2-digit", +}; + +/** Format Unix seconds in the viewer's locale and time zone by default. */ +export function formatDate( + timestamp: number, + locale?: string | string[], + timeZone?: string +): string { + return new Intl.DateTimeFormat(locale, { + ...DATE_TIME_OPTIONS, + ...(timeZone ? { timeZone } : {}), + }).format(new Date(timestamp * 1000)); +} + +/** Format Unix seconds as a localized time of day. */ +export function formatTime( + timestamp: number, + locale?: string | string[], + timeZone?: string +): string { + return new Intl.DateTimeFormat(locale, { + ...TIME_OPTIONS, + ...(timeZone ? { timeZone } : {}), + }).format(new Date(timestamp * 1000)); /** * Format a Unix timestamp (seconds) to a locale-aware date/time string. *