Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions frontend/src/__tests__/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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({
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
formatXLM,
truncateAddress,
isValidAmount,
formatDate,
formatTime,
timeUntil,
formatDate,
formatTime,
Expand All @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/markets/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/services/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}))$/;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export interface MarketEvent {
user: string;
marketId: number;
amount?: number;
/** Unix timestamp in seconds. */
timestamp: number;
txHash: string;
}
35 changes: 35 additions & 0 deletions frontend/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down