Skip to content
Open
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
69 changes: 69 additions & 0 deletions frontend/src/__tests__/events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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<typeof import("@stellar/stellar-sdk")>(
"@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", () => {
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",
},
],
});

await expect(pollMarketEvents()).resolves.toEqual([]);
});
});
34 changes: 34 additions & 0 deletions frontend/src/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
truncateAddress,
isValidAmount,
timeUntil,
formatDate,
formatTime,
calculatePayout,
calculateOdds,
bpsToPercent,
Expand Down Expand Up @@ -171,6 +173,38 @@ 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 UTC"
);
});

it("respects the requested locale", () => {
expect(formatDate(timestampMs, "de-DE", "UTC")).toBe(
"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 ───────────────────────────────────────────────────────────

describe("calculatePayout", () => {
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/__tests__/useLeaderboard.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
9 changes: 7 additions & 2 deletions frontend/src/app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<LeaderboardTab>("top_predictors");
const { data: players, loading, error } = useLeaderboard(tab);
const { data: players, loading, error, lastUpdated } = useLeaderboard(tab);
const { publicKey } = useWallet();

return (
Expand All @@ -31,6 +32,11 @@ export default function LeaderboardPage() {
<p className="text-slate-400">
Rankings update in real-time from onchain data.
</p>
{lastUpdated !== null && (
<p className="mt-1 text-xs text-slate-500">
Last updated: {formatDate(lastUpdated)}
</p>
)}
</div>

{/* Tabs */}
Expand Down Expand Up @@ -77,4 +83,3 @@ export default function LeaderboardPage() {
</div>
);
}

10 changes: 8 additions & 2 deletions frontend/src/app/markets/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
formatTime,
calculatePayout,
truncateAddress,
} from "@/utils/helpers";
import {
WIN_POINTS,
LOSE_POINTS,
Expand Down Expand Up @@ -319,7 +325,7 @@ export default function MarketDetailPage({
</span>
</div>
<span className="text-xs text-slate-600 shrink-0">
{new Date(evt.timestamp * 1000).toLocaleTimeString()}
{formatTime(evt.timestamp)}
</span>
</div>
))}
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/hooks/useLeaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface UseLeaderboardResult {
data: PlayerStats[];
loading: boolean;
error: string | null;
lastUpdated: number | null;
refetch: () => void;
}

Expand Down Expand Up @@ -107,6 +108,7 @@ export function useLeaderboard(
const [data, setData] = useState<PlayerStats[]>([]);
const [loading, setLoading] = useState(!seeded.current);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<number | null>(null);
const mountedRef = useRef(true);
const initialLoadDone = useRef(false);

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -189,5 +192,5 @@ export function useLeaderboard(
fetchData();
}, [fetchData]);

return { data, loading, error, refetch };
return { data, loading, error, lastUpdated, refetch };
}
27 changes: 26 additions & 1 deletion frontend/src/services/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,31 @@ 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 {
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 ────────────────────────────

function parseEventResponse(
Expand All @@ -32,7 +57,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":
Expand Down
47 changes: 43 additions & 4 deletions frontend/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,56 @@ 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",
});
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));
}

/**
Expand Down