From 8e2aaa92884ec4d64c06354f332dcd5b762744a2 Mon Sep 17 00:00:00 2001 From: CrazyShout <63629786+CrazyShout@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:04:44 +0800 Subject: [PATCH 1/3] fix: normalize localized leaderboard timestamps --- frontend/src/__tests__/helpers.test.ts | 26 +++++++ .../src/__tests__/useLeaderboard.test.tsx | 78 +++++++++++++++++++ frontend/src/app/leaderboard/page.tsx | 9 ++- frontend/src/app/markets/[id]/page.tsx | 10 ++- frontend/src/hooks/useLeaderboard.ts | 5 +- frontend/src/utils/helpers.ts | 23 +++++- 6 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 frontend/src/__tests__/useLeaderboard.test.tsx diff --git a/frontend/src/__tests__/helpers.test.ts b/frontend/src/__tests__/helpers.test.ts index c039046..06d668e 100644 --- a/frontend/src/__tests__/helpers.test.ts +++ b/frontend/src/__tests__/helpers.test.ts @@ -4,6 +4,7 @@ import { truncateAddress, isValidAmount, timeUntil, + formatDate, calculatePayout, calculateOdds, bpsToPercent, @@ -171,6 +172,31 @@ describe("timeUntil", () => { }); }); +// ── formatDate ──────────────────────────────────────────────────────────────── + +describe("formatDate", () => { + const timestampMs = Date.UTC(2026, 1, 26, 15, 4); + const timestampSeconds = timestampMs / 1000; + + it("normalizes Unix seconds and milliseconds to the same instant", () => { + expect(formatDate(timestampSeconds, "en-US", "UTC")).toBe( + formatDate(timestampMs, "en-US", "UTC") + ); + }); + + it("formats a complete date and time consistently", () => { + expect(formatDate(timestampMs, "en-US", "UTC")).toBe( + "Feb 26, 2026, 03:04 PM" + ); + }); + + it("respects the requested locale", () => { + expect(formatDate(timestampMs, "de-DE", "UTC")).toBe( + "26. Feb. 2026, 15:04" + ); + }); +}); + // ── calculatePayout ─────────────────────────────────────────────────────────── describe("calculatePayout", () => { diff --git a/frontend/src/__tests__/useLeaderboard.test.tsx b/frontend/src/__tests__/useLeaderboard.test.tsx new file mode 100644 index 0000000..15ba502 --- /dev/null +++ b/frontend/src/__tests__/useLeaderboard.test.tsx @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; + +const mocks = vi.hoisted(() => ({ + getTopPlayers: vi.fn(), + getStats: vi.fn(), + getMarkets: vi.fn(), + getMarketBettors: vi.fn(), + getDisplayName: vi.fn(), + cacheSet: vi.fn(), +})); + +vi.mock("@/services/leaderboard", () => ({ + getTopPlayers: mocks.getTopPlayers, + getStats: mocks.getStats, +})); + +vi.mock("@/services/market", () => ({ + getMarkets: mocks.getMarkets, + getMarketBettors: mocks.getMarketBettors, +})); + +vi.mock("@/services/referral", () => ({ + getDisplayName: mocks.getDisplayName, +})); + +vi.mock("@/services/cache", () => ({ + getStale: () => null, + set: mocks.cacheSet, +})); + +vi.mock("@/hooks/useVisiblePoll", () => ({ + useVisiblePoll: vi.fn(), +})); + +import { useLeaderboard } from "@/hooks/useLeaderboard"; + +describe("useLeaderboard lastUpdated", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getMarkets.mockResolvedValue([]); + }); + + it("records a millisecond timestamp after a successful refresh", async () => { + const refreshedAt = 1_800_000_000_123; + vi.spyOn(Date, "now").mockReturnValue(refreshedAt); + mocks.getTopPlayers.mockResolvedValue([ + { + address: "GALICE", + displayName: "Alice", + points: 100, + totalBets: 4, + wonBets: 3, + lostBets: 1, + winRate: 75, + }, + ]); + + const { result } = renderHook(() => useLeaderboard("top_predictors")); + + expect(result.current.lastUpdated).toBeNull(); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.data).toHaveLength(1); + expect(result.current.lastUpdated).toBe(refreshedAt); + }); + + it("does not claim a refresh time when loading fails", async () => { + mocks.getTopPlayers.mockRejectedValue(new Error("RPC unavailable")); + + const { result } = renderHook(() => useLeaderboard("top_predictors")); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.error).toBe("RPC unavailable"); + expect(result.current.lastUpdated).toBeNull(); + }); +}); diff --git a/frontend/src/app/leaderboard/page.tsx b/frontend/src/app/leaderboard/page.tsx index 4c00b27..93c52f7 100644 --- a/frontend/src/app/leaderboard/page.tsx +++ b/frontend/src/app/leaderboard/page.tsx @@ -8,11 +8,12 @@ import LeaderboardTable from "@/components/leaderboard/LeaderboardTable"; import Skeleton from "@/components/ui/Skeleton"; import EmptyState from "@/components/ui/EmptyState"; import ErrorBoundary from "@/components/ui/ErrorBoundary"; +import { formatDate } from "@/utils/helpers"; import { FiAward } from "react-icons/fi"; export default function LeaderboardPage() { const [tab, setTab] = useState("top_predictors"); - const { data: players, loading, error } = useLeaderboard(tab); + const { data: players, loading, error, lastUpdated } = useLeaderboard(tab); const { publicKey } = useWallet(); return ( @@ -31,6 +32,11 @@ export default function LeaderboardPage() {

Rankings update in real-time from onchain data.

+ {lastUpdated !== null && ( +

+ Last updated: {formatDate(lastUpdated)} +

+ )} {/* Tabs */} @@ -77,4 +83,3 @@ export default function LeaderboardPage() { ); } - diff --git a/frontend/src/app/markets/[id]/page.tsx b/frontend/src/app/markets/[id]/page.tsx index 5ae3a63..b779f16 100644 --- a/frontend/src/app/markets/[id]/page.tsx +++ b/frontend/src/app/markets/[id]/page.tsx @@ -7,7 +7,13 @@ import { useWallet } from "@/hooks/useWallet"; import { useToken } from "@/hooks/useToken"; import { pollMarketEvents } from "@/services/events"; import { getXlmBalance } from "@/services/soroban"; -import { displayXLM, formatXLM, calculatePayout, truncateAddress } from "@/utils/helpers"; +import { + displayXLM, + formatXLM, + formatDate, + calculatePayout, + truncateAddress, +} from "@/utils/helpers"; import { WIN_POINTS, LOSE_POINTS, @@ -319,7 +325,7 @@ export default function MarketDetailPage({ - {new Date(evt.timestamp * 1000).toLocaleTimeString()} + {formatDate(evt.timestamp)} ))} diff --git a/frontend/src/hooks/useLeaderboard.ts b/frontend/src/hooks/useLeaderboard.ts index 10a7bc0..c9aa4af 100644 --- a/frontend/src/hooks/useLeaderboard.ts +++ b/frontend/src/hooks/useLeaderboard.ts @@ -22,6 +22,7 @@ interface UseLeaderboardResult { data: PlayerStats[]; loading: boolean; error: string | null; + lastUpdated: number | null; refetch: () => void; } @@ -107,6 +108,7 @@ export function useLeaderboard( const [data, setData] = useState([]); const [loading, setLoading] = useState(!seeded.current); const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); const mountedRef = useRef(true); const initialLoadDone = useRef(false); @@ -153,6 +155,7 @@ export function useLeaderboard( // Persist assembled leaderboard for instant stale-seed next time cache.set(LB_CACHE_KEY, players, 60_000); setAllPlayers(players); + setLastUpdated(Date.now()); } catch (err) { if (!mountedRef.current) return; setError( @@ -189,5 +192,5 @@ export function useLeaderboard( fetchData(); }, [fetchData]); - return { data, loading, error, refetch }; + return { data, loading, error, lastUpdated, refetch }; } diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index 4688130..5bde59d 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -74,17 +74,32 @@ export function timeUntil(timestamp: number): string { return `${seconds}s`; } +/** Values below this threshold are Unix seconds; larger values are milliseconds. */ +const MILLISECOND_TIMESTAMP_THRESHOLD = 100_000_000_000; + /** - * Format a Unix timestamp to a locale-aware date string. + * Format a seconds- or milliseconds-based Unix timestamp in the user's locale. + * Supplying a locale and time zone is useful for deterministic rendering/tests; + * browser defaults are used in the application. */ -export function formatDate(timestamp: number): string { - return new Date(timestamp * 1000).toLocaleDateString("en-US", { +export function formatDate( + timestamp: number, + locale?: string, + timeZone?: string +): string { + const timestampMs = + Math.abs(timestamp) < MILLISECOND_TIMESTAMP_THRESHOLD + ? timestamp * 1000 + : timestamp; + + return new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", - }); + ...(timeZone ? { timeZone } : {}), + }).format(new Date(timestampMs)); } /** From e36b532f2a9ce0017193edce57345933fdfadd12 Mon Sep 17 00:00:00 2001 From: CrazyShout <63629786+CrazyShout@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:33:55 +0800 Subject: [PATCH 2/3] test: harden timestamp normalization coverage --- frontend/src/__tests__/events.test.ts | 12 ++++++++++++ frontend/src/__tests__/helpers.test.ts | 12 ++++++++++-- frontend/src/app/markets/[id]/page.tsx | 4 ++-- frontend/src/services/events.ts | 9 ++++++++- frontend/src/utils/helpers.ts | 24 ++++++++++++++++++++++++ 5 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 frontend/src/__tests__/events.test.ts diff --git a/frontend/src/__tests__/events.test.ts b/frontend/src/__tests__/events.test.ts new file mode 100644 index 0000000..eaf5f75 --- /dev/null +++ b/frontend/src/__tests__/events.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { ledgerClosedAtToUnixSeconds } from "@/services/events"; + +describe("event timestamp parsing", () => { + it("normalizes ledgerClosedAt to Unix seconds for UI formatters", () => { + const closedAt = "2026-02-26T15:04:00.000Z"; + + expect(ledgerClosedAtToUnixSeconds(closedAt)).toBe( + Date.UTC(2026, 1, 26, 15, 4) / 1000 + ); + }); +}); diff --git a/frontend/src/__tests__/helpers.test.ts b/frontend/src/__tests__/helpers.test.ts index 06d668e..0d616a2 100644 --- a/frontend/src/__tests__/helpers.test.ts +++ b/frontend/src/__tests__/helpers.test.ts @@ -5,6 +5,7 @@ import { isValidAmount, timeUntil, formatDate, + formatTime, calculatePayout, calculateOdds, bpsToPercent, @@ -186,15 +187,22 @@ describe("formatDate", () => { it("formats a complete date and time consistently", () => { expect(formatDate(timestampMs, "en-US", "UTC")).toBe( - "Feb 26, 2026, 03:04 PM" + "Feb 26, 2026, 03:04 PM UTC" ); }); it("respects the requested locale", () => { expect(formatDate(timestampMs, "de-DE", "UTC")).toBe( - "26. Feb. 2026, 15:04" + "26. Feb. 2026, 15:04 UTC" ); }); + + it("keeps compact times consistent across timestamp units", () => { + expect(formatTime(timestampSeconds, "en-US", "UTC")).toBe( + formatTime(timestampMs, "en-US", "UTC") + ); + expect(formatTime(timestampMs, "en-US", "UTC")).toBe("03:04 PM UTC"); + }); }); // ── calculatePayout ─────────────────────────────────────────────────────────── diff --git a/frontend/src/app/markets/[id]/page.tsx b/frontend/src/app/markets/[id]/page.tsx index b779f16..0e39ee8 100644 --- a/frontend/src/app/markets/[id]/page.tsx +++ b/frontend/src/app/markets/[id]/page.tsx @@ -10,7 +10,7 @@ import { getXlmBalance } from "@/services/soroban"; import { displayXLM, formatXLM, - formatDate, + formatTime, calculatePayout, truncateAddress, } from "@/utils/helpers"; @@ -325,7 +325,7 @@ export default function MarketDetailPage({ - {formatDate(evt.timestamp)} + {formatTime(evt.timestamp)} ))} diff --git a/frontend/src/services/events.ts b/frontend/src/services/events.ts index a30c32d..0eb0a42 100644 --- a/frontend/src/services/events.ts +++ b/frontend/src/services/events.ts @@ -19,6 +19,13 @@ function isKnownEventType(s: string): s is ContractEventType { return (EVENT_TYPES as readonly string[]).includes(s); } +/** Keep the MarketEvent timestamp contract in Unix seconds. */ +export function ledgerClosedAtToUnixSeconds( + ledgerClosedAt: string | number | Date +): number { + return Math.floor(new Date(ledgerClosedAt).getTime() / 1000); +} + // ── Parse a single event response into MarketEvent ──────────────────────────── function parseEventResponse( @@ -32,7 +39,7 @@ function parseEventResponse( if (!isKnownEventType(eventName)) return null; const data = scValToNative(event.value); - const timestamp = new Date(event.ledgerClosedAt).getTime(); + const timestamp = ledgerClosedAtToUnixSeconds(event.ledgerClosedAt); switch (eventName) { case "bet_placed": diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index 5bde59d..5338559 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -98,6 +98,30 @@ export function formatDate( day: "numeric", hour: "2-digit", minute: "2-digit", + timeZoneName: "short", + ...(timeZone ? { timeZone } : {}), + }).format(new Date(timestampMs)); +} + +/** + * Format a seconds- or milliseconds-based Unix timestamp as a compact time. + * This uses the same unit normalization and locale/time-zone rules as + * `formatDate`, while preserving compact activity-feed layouts. + */ +export function formatTime( + timestamp: number, + locale?: string, + timeZone?: string +): string { + const timestampMs = + Math.abs(timestamp) < MILLISECOND_TIMESTAMP_THRESHOLD + ? timestamp * 1000 + : timestamp; + + return new Intl.DateTimeFormat(locale, { + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", ...(timeZone ? { timeZone } : {}), }).format(new Date(timestampMs)); } From c011841edfd6c842d006cb32290fe89a6119c2e6 Mon Sep 17 00:00:00 2001 From: CrazyShout <63629786+CrazyShout@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:11:39 +0800 Subject: [PATCH 3/3] test: reject malformed ledger timestamps --- frontend/src/__tests__/events.test.ts | 71 ++++++++++++++++++++++++--- frontend/src/services/events.ts | 20 +++++++- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/frontend/src/__tests__/events.test.ts b/frontend/src/__tests__/events.test.ts index eaf5f75..9447ed7 100644 --- a/frontend/src/__tests__/events.test.ts +++ b/frontend/src/__tests__/events.test.ts @@ -1,12 +1,69 @@ -import { describe, expect, it } from "vitest"; -import { ledgerClosedAtToUnixSeconds } from "@/services/events"; +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getLatestLedger: vi.fn(), + getEvents: vi.fn(), +})); + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual( + "@stellar/stellar-sdk" + ); + return { ...actual, scValToNative: (value: unknown) => value }; +}); + +vi.mock("@/services/soroban", () => ({ + getSorobanServer: () => ({ + getLatestLedger: mocks.getLatestLedger, + getEvents: mocks.getEvents, + }), +})); + +import { + ledgerClosedAtToUnixSeconds, + pollMarketEvents, +} from "@/services/events"; describe("event timestamp parsing", () => { - it("normalizes ledgerClosedAt to Unix seconds for UI formatters", () => { - const closedAt = "2026-02-26T15:04:00.000Z"; + 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 object", new Date("2026-02-26T15:04:00.000Z")], + ])("normalizes a valid %s to Unix seconds", (_label, value) => { + expect(ledgerClosedAtToUnixSeconds(value)).toBe(expected); + }); + + it.each([ + ["invalid string", "not-a-date"], + ["empty string", ""], + ["whitespace string", " "], + ["missing value", undefined], + ["null", null], + ["NaN", Number.NaN], + ["positive infinity", Number.POSITIVE_INFINITY], + ["negative infinity", Number.NEGATIVE_INFINITY], + ["invalid Date", new Date(Number.NaN)], + ])("rejects %s", (_label, value) => { + expect(() => + ledgerClosedAtToUnixSeconds(value as unknown as string) + ).toThrow(new RangeError("Invalid ledger close timestamp")); + }); + + it("discards an event whose close time cannot satisfy the timestamp contract", async () => { + mocks.getLatestLedger.mockResolvedValue({ sequence: 100 }); + mocks.getEvents.mockResolvedValue({ + events: [ + { + topic: ["market_cancelled", 7], + value: {}, + ledgerClosedAt: "not-a-date", + txHash: "malformed-event", + }, + ], + }); - expect(ledgerClosedAtToUnixSeconds(closedAt)).toBe( - Date.UTC(2026, 1, 26, 15, 4) / 1000 - ); + await expect(pollMarketEvents()).resolves.toEqual([]); }); }); diff --git a/frontend/src/services/events.ts b/frontend/src/services/events.ts index 0eb0a42..fa0ffe1 100644 --- a/frontend/src/services/events.ts +++ b/frontend/src/services/events.ts @@ -23,7 +23,25 @@ function isKnownEventType(s: string): s is ContractEventType { export function ledgerClosedAtToUnixSeconds( ledgerClosedAt: string | number | Date ): number { - return Math.floor(new Date(ledgerClosedAt).getTime() / 1000); + if ( + (typeof ledgerClosedAt === "string" && ledgerClosedAt.trim() === "") || + (typeof ledgerClosedAt !== "string" && + typeof ledgerClosedAt !== "number" && + !(ledgerClosedAt instanceof Date)) + ) { + throw new RangeError("Invalid ledger close timestamp"); + } + + 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); } // ── Parse a single event response into MarketEvent ────────────────────────────