diff --git a/frontend/src/__tests__/components/Navbar.test.tsx b/frontend/src/__tests__/components/Navbar.test.tsx index 69d7287..222a0ec 100644 --- a/frontend/src/__tests__/components/Navbar.test.tsx +++ b/frontend/src/__tests__/components/Navbar.test.tsx @@ -70,7 +70,7 @@ describe("Navbar", () => { it("renders the logo / brand name", () => { render(); - expect(screen.getByText(/Stellar Pulse/i)).toBeInTheDocument(); + expect(screen.getByText(/StellarPulse/i)).toBeInTheDocument(); }); it("renders all navigation links", () => { @@ -87,7 +87,7 @@ describe("Navbar", () => { it("logo links to home page", () => { render(); - const logo = screen.getByText(/Stellar Pulse/i); + const logo = screen.getByText(/StellarPulse/i); expect(logo.closest("a")).toHaveAttribute("href", "/"); }); diff --git a/frontend/src/__tests__/events.test.ts b/frontend/src/__tests__/events.test.ts index effe0d6..d4d0fbc 100644 --- a/frontend/src/__tests__/events.test.ts +++ b/frontend/src/__tests__/events.test.ts @@ -1,18 +1,4 @@ -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-26T02:05:30.000Z"; - - expect(ledgerClosedAtToUnixSeconds(closedAt)).toBe( - Date.UTC(2026, 1, 26, 2, 5, 30) / 1000 - ); - }); - - it("preserves numeric Unix seconds instead of treating them as milliseconds", () => { - expect(ledgerClosedAtToUnixSeconds(1771985130)).toBe(1771985130); -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ getLatestLedger: vi.fn(), @@ -38,66 +24,69 @@ import { pollMarketEvents, } from "@/services/events"; -describe("ledgerClosedAtToUnixSeconds", () => { - const expected = Date.UTC(2026, 1, 26, 15, 4) / 1000; +describe("ledger close timestamp parsing", () => { + const expectedSeconds = Date.UTC(2026, 1, 26, 15, 4) / 1_000; + + beforeEach(() => { + vi.clearAllMocks(); + }); 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); + ["numeric seconds", expectedSeconds], + ["numeric milliseconds", expectedSeconds * 1_000], + ["Date object", new Date("2026-02-26T15:04:00.000Z")], + ])("normalizes a valid %s to Unix seconds", (_label, value) => { + expect(ledgerClosedAtToUnixSeconds(value)).toBe(expectedSeconds); + }); + + it("honors an explicit ISO timezone offset", () => { + expect(ledgerClosedAtToUnixSeconds("2026-02-26T10:04:00-05:00")).toBe( + expectedSeconds + ); }); it.each([ ["invalid string", "not-a-date"], + ["empty string", ""], + ["whitespace string", " "], + ["timezone-less string", "2026-02-26T15:04:00"], + ["impossible date", "2026-02-30T15:04:00Z"], + ["invalid hour", "2026-02-26T25:04:00Z"], + ["invalid offset", "2026-02-26T15:04:00+24:00"], + ["zero", 0], + ["negative", -1], ["missing value", undefined], + ["null", null], ["NaN", Number.NaN], ["positive infinity", Number.POSITIVE_INFINITY], ["negative infinity", Number.NEGATIVE_INFINITY], - ])("throws RangeError for %s", (_label, value) => { + ["invalid Date", new Date(Number.NaN)], + ])("rejects %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([ - ["ISO timestamp", "2026-07-18T08:30:00.000Z"], - ["offset timestamp", "2026-07-18T16:30:00+08:00"], - ["Date instance", new Date("2026-07-18T08:30:00.000Z")], - ["Unix milliseconds", expectedSeconds * 1000], - ["Unix seconds", expectedSeconds], - ])("normalizes a valid %s", (_label, value) => { - expect(ledgerClosedAtToUnixSeconds(value)).toBe(expectedSeconds); }); - it.each([ - ["blank value", ""], - ["invalid string", "not-a-date"], - ["impossible calendar date", "2026-02-31T08:30:00Z"], - ["timestamp without a time zone", "2026-07-18T08:30:00"], - ["NaN", Number.NaN], - ["positive infinity", Number.POSITIVE_INFINITY], - ["invalid Date", new Date(Number.NaN)], - ])("rejects %s", (_label, value) => { - expect(() => ledgerClosedAtToUnixSeconds(value)).toThrow( - new RangeError("Invalid ledger close timestamp") - ); - }); -}); + it("keeps parsed MarketEvent timestamps in Unix seconds", async () => { + mocks.getLatestLedger.mockResolvedValue({ sequence: 100 }); + mocks.getEvents.mockResolvedValue({ + events: [ + { + topic: ["market_cancelled", 7], + value: {}, + ledgerClosedAt: "2026-02-26T15:04:00.000Z", + txHash: "valid-event", + }, + ], + }); -describe("pollMarketEvents", () => { - it("discards an event with malformed ledgerClosedAt", async () => { - const malformedEvent = { - topic: ["market_cancelled", 7], - value: {}, - ledgerClosedAt: "not-a-date", - txHash: "malformed-event", - }; + await expect(pollMarketEvents()).resolves.toEqual([ + expect.objectContaining({ timestamp: expectedSeconds }), + ]); + }); - mocks.getLatestLedger.mockResolvedValue({ sequence: 100 }); - mocks.getEvents.mockResolvedValue({ events: [malformedEvent] }); - it("drops an event with a malformed close timestamp", async () => { + it("discards an event whose close time violates the timestamp contract", async () => { mocks.getLatestLedger.mockResolvedValue({ sequence: 100 }); mocks.getEvents.mockResolvedValue({ events: [ diff --git a/frontend/src/__tests__/helpers.test.ts b/frontend/src/__tests__/helpers.test.ts index 1a1ab2b..efc1bb4 100644 --- a/frontend/src/__tests__/helpers.test.ts +++ b/frontend/src/__tests__/helpers.test.ts @@ -1,554 +1,251 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; -import { formatDate, timeAgo } from "@/utils/helpers"; - -// --------------------------------------------------------------------------- -// formatDate -// --------------------------------------------------------------------------- - -describe("formatDate", () => { - it("accepts Unix seconds and returns a non-empty string", () => { - // 2024-01-15 12:00:00 UTC in seconds - const result = formatDate(1705320000); - expect(typeof result).toBe("string"); - expect(result.length).toBeGreaterThan(0); - }); - - it("accepts millisecond timestamps without doubling", () => { - // Same instant expressed in milliseconds (> 4_102_444_800 guard) - const seconds = 1705320000; - const ms = seconds * 1000; - const fromSeconds = formatDate(seconds); - const fromMs = formatDate(ms); - // Both should produce the same formatted date - expect(fromSeconds).toBe(fromMs); - }); - - it("does not hard-code en-US locale (uses undefined locale)", () => { - // Spy on toLocaleString to confirm undefined locale is passed - const spy = vi.spyOn(Date.prototype, "toLocaleString"); - formatDate(1705320000); - expect(spy).toHaveBeenCalledWith( - undefined, - expect.objectContaining({ year: "numeric", month: "short" }) -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - formatXLM, - truncateAddress, - isValidAmount, - formatDate, - formatTime, - timeUntil, - formatDate, - formatTime, - calculatePayout, - calculateOdds, bpsToPercent, - explorerUrl,, - formatDate, - formatTime, - toTimestampMs} from "@/utils/helpers"; + calculateOdds, + calculatePayout, + displayXLM, explorerUrl, formatDate, - formatEventTime, + formatTime, + formatXLM, + isValidAmount, timeAgo, + timeUntil, + toTimestampMs, + truncateAddress, } from "@/utils/helpers"; -describe("localized timestamp formatters", () => { - const timestamp = Date.UTC(2026, 2, 1, 1, 30) / 1000; - +describe("XLM formatting", () => { 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", () => { - it("formats whole XLM correctly", () => { - expect(formatXLM(100_0000000n)).toBe("100 XLM"); + [100_0000000n, "100 XLM"], + [123_4567890n, "123.456789 XLM"], + [-5_5000000n, "-5.5 XLM"], + [-50_0000000n, "-50 XLM"], + [10_1000000n, "10.1 XLM"], + [1_000_001n, "0.1000001 XLM"], + [1_000_000_000_0000000n, "1000000000 XLM"], + [1n, "0.0000001 XLM"], + [0n, "0 XLM"], + ])("formats %s stroops", (value, expected) => { + expect(formatXLM(value)).toBe(expected); }); - it("formats fractional XLM correctly", () => { - expect(formatXLM(123_4567890n)).toBe("123.456789 XLM"); - }); - - it("handles zero", () => { - expect(formatXLM(0n)).toBe("0 XLM"); - }); - - it("handles negative values", () => { - expect(formatXLM(-50_0000000n)).toBe("-50 XLM"); - }); - - it("handles small stroops (less than 1 XLM)", () => { - expect(formatXLM(1n)).toBe("0.0000001 XLM"); - }); - - it("handles very large amounts", () => { - // 1 billion XLM - expect(formatXLM(1_000_000_000_0000000n)).toBe("1000000000 XLM"); - }); - - it("handles negative fractional values", () => { - expect(formatXLM(-5_5000000n)).toBe("-5.5 XLM"); - }); - - it("strips trailing zeros from fractional part", () => { - // 10.1 XLM = 10_1000000 stroops - expect(formatXLM(10_1000000n)).toBe("10.1 XLM"); - }); - - it("handles exactly 1 stroop", () => { - expect(formatXLM(1n)).toBe("0.0000001 XLM"); + it.each([ + [12.5, "12.5 XLM"], + [-12.5, "-12.5 XLM"], + [0, "0 XLM"], + [12.345, "12.35 XLM"], + ])("displays %s XLM", (value, expected) => { + expect(displayXLM(value)).toBe(expected); }); }); -// ── truncateAddress ─────────────────────────────────────────────────────────── - -describe("truncateAddress", () => { - it("truncates a standard 56-char Stellar address", () => { - const addr = "GDHQ6TNWZ4V2JVCDWEUVW7YKFBXCOQZRRUCT27LAKES3PGOE6JSZMSMD"; - expect(truncateAddress(addr)).toBe("GDHQ...MSMD"); - }); - - it("truncates long addresses", () => { +describe("address and amount helpers", () => { + it("truncates long Stellar addresses", () => { expect(truncateAddress("GABCDEFGHIJKLMNOPQRSTUVWXYZ234567")).toBe( "GABC...4567" ); - spy.mockRestore(); - }); - - it("includes hour and minute in the output options", () => { - const spy = vi.spyOn(Date.prototype, "toLocaleString"); - formatDate(1705320000); - expect(spy).toHaveBeenCalledWith( - undefined, - expect.objectContaining({ hour: "2-digit", minute: "2-digit" }) - ); - spy.mockRestore(); - }); -}); - -// --------------------------------------------------------------------------- -// timeAgo -// --------------------------------------------------------------------------- - -describe("timeAgo", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("returns 'just now' for a timestamp within the last 5 seconds", () => { - vi.useFakeTimers(); - const now = 1_700_000_000; // seconds - vi.setSystemTime(now * 1000); - expect(timeAgo(now - 2)).toBe("just now"); - it('returns "Ended" for past timestamps', () => { - expect(timeUntil(0)).toBe("Ended"); - }); - - it('returns "Ended" for timestamp equal to now', () => { - const now = Math.floor(Date.now() / 1000); - expect(timeUntil(now)).toBe("Ended"); - }); - - it("returns days/hours/minutes for future timestamp", () => { - const now = Math.floor(Date.now() / 1000); - // 2 days, 3 hours, 45 minutes from now - const future = now + 2 * 86400 + 3 * 3600 + 45 * 60; - expect(timeUntil(future)).toBe("2d 3h 45m"); }); - it("returns hours/minutes when less than a day", () => { - const now = Math.floor(Date.now() / 1000); - const future = now + 5 * 3600 + 30 * 60; - expect(timeUntil(future)).toBe("5h 30m"); + it("leaves short values unchanged", () => { + expect(truncateAddress("SHORT")).toBe("SHORT"); + expect(truncateAddress("")).toBe(""); }); - it("returns minutes only when less than an hour", () => { - const now = Math.floor(Date.now() / 1000); - const future = now + 42 * 60; - expect(timeUntil(future)).toBe("42m"); - }); - - it("returns seconds when less than a minute", () => { - const now = Math.floor(Date.now() / 1000); - const future = now + 30; - expect(timeUntil(future)).toBe("30s"); - }); -}); - -// ── formatDate ──────────────────────────────────────────────────────────────── - -describe("formatDate", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-02-26T12:00:00Z")); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("formats a Unix timestamp into a readable date string", () => { - const ts = Math.floor(new Date("2026-01-15T10:30:00Z").getTime() / 1000); - const result = formatDate(ts); - expect(result).toMatch(/2026/); - expect(result).toMatch(/Jan/); - expect(result).toMatch(/15/); - }); - - it("includes time components (hour and minute)", () => { - const ts = Math.floor(new Date("2026-01-15T10:30:00Z").getTime() / 1000); - const result = formatDate(ts); - expect(result).toMatch(/\d/); - }); - - it("does not hardcode en-US locale (uses undefined for user locale)", () => { - const ts = Math.floor(new Date("2026-01-15T10:30:00Z").getTime() / 1000); - const result = formatDate(ts); - expect(result).toBeTruthy(); - expect(result.length).toBeGreaterThan(10); + it.each([ + ["ABCDEFGHIJ", "ABCDEFGHIJ"], + ["ABCDEFGHIJK", "ABCD...HIJK"], + [ + "GDHQ6TNWZ4V2JVCDWEUVW7YKFBXCOQZRRUCT27LAKES3PGOE6JSZMSMD", + "GDHQ...MSMD", + ], + ])("handles address truncation boundary for %s", (address, expected) => { + expect(truncateAddress(address)).toBe(expected); }); - it("handles epoch zero", () => { - expect(formatDate(0)).toBeTruthy(); + it("validates positive amounts against the balance", () => { + expect(isValidAmount("1", 100)).toBe(true); + expect(isValidAmount("100", 100)).toBe(true); + expect(isValidAmount("0.5", 100)).toBe(false); + expect(isValidAmount("101", 100)).toBe(false); + expect(isValidAmount("abc", 100)).toBe(false); }); }); -// ── formatTime ──────────────────────────────────────────────────────────────── +describe("timestamp helpers", () => { + const seconds = Date.UTC(2026, 1, 26, 15, 4) / 1_000; + const milliseconds = seconds * 1_000; + const options: Intl.DateTimeFormatOptions = { timeZone: "UTC" }; -describe("formatTime", () => { beforeEach(() => { vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-02-26T12:00:00Z")); + vi.setSystemTime(new Date("2026-02-26T17:04:00.000Z")); }); afterEach(() => { vi.useRealTimers(); }); - it("formats a Unix timestamp into a time string", () => { - const ts = Math.floor(new Date("2026-01-15T10:30:00Z").getTime() / 1000); - const result = formatTime(ts); - expect(result).toMatch(/\d/); - }); - - it("returns a non-empty string for valid timestamp", () => { - const ts = Math.floor(new Date("2026-01-15T10:30:00Z").getTime() / 1000); - expect(formatTime(ts).length).toBeGreaterThan(0); - }); - - it("handles epoch zero", () => { - expect(formatTime(0)).toBeTruthy(); -// ── timestamp formatting ───────────────────────────────────────────────────── - -describe("timestamp formatting", () => { + it.each([ + ["Unix seconds", seconds, milliseconds], + ["Unix milliseconds", milliseconds, milliseconds], + ["fractional seconds", seconds + 0.125, milliseconds + 125], + ["one second", 1, 1_000], + ["one millisecond at the threshold", 100_000_000_000, 100_000_000_000], + ])("normalizes %s", (_label, value, expected) => { + expect(toTimestampMs(value)).toBe(expected); + }); + + it("formats seconds and milliseconds as the same timezone-aware date", () => { + const expected = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + timeZone: "UTC", + }).format(new Date(milliseconds)); + + expect(formatDate(seconds, "en-US", options)).toBe(expected); + expect(formatDate(milliseconds, "en-US", options)).toBe(expected); + }); + + it("formats the local time with an explicit timezone label", () => { + const expected = new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + timeZone: "UTC", + }).format(new Date(milliseconds)); + + expect(formatTime(seconds, "en-US", options)).toBe(expected); + expect(formatTime(milliseconds, "en-US", options)).toBe(expected); }); -}); -// ── calculatePayout ─────────────────────────────────────────────────────────── - -describe("timestamp formatting", () => { - const timestamp = Date.UTC(2026, 1, 26, 2, 5) / 1000; - - it("formats date/time with the provided locale and time zone", () => { - const options = { timeZone: "UTC" }; - - expect(formatDate(timestamp, "en-US", options)).toBe( - new Intl.DateTimeFormat("en-US", { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZone: "UTC", - }).format(new Date(timestamp * 1000)) - ); + it.each([ + ["en-US", "America/New_York"], + ["en-GB", "Europe/London"], + ["de-DE", "Europe/Berlin"], + ["ja-JP", "Asia/Tokyo"], + ])("honors the %s locale and %s timezone", (locale, timeZone) => { + const dateOptions: Intl.DateTimeFormatOptions = { timeZone }; + const expectedDate = new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + timeZone, + }).format(new Date(milliseconds)); + const expectedTime = new Intl.DateTimeFormat(locale, { + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + timeZone, + }).format(new Date(milliseconds)); + + expect(formatDate(seconds, locale, dateOptions)).toBe(expectedDate); + expect(formatTime(seconds, locale, dateOptions)).toBe(expectedTime); }); it("does not hard-code a single locale", () => { - const options = { timeZone: "UTC" }; - - expect(formatDate(timestamp, "de-DE", options)).not.toBe( - formatDate(timestamp, "en-US", options) + expect(formatDate(seconds, "de-DE", options)).not.toBe( + formatDate(seconds, "en-US", options) ); }); - it("formats compact local times through the shared helper", () => { - const options = { timeZone: "UTC" }; + it.each([ + 0, + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + Number.MAX_VALUE, + ])( + "rejects invalid display timestamp %s", + (value) => { + expect(formatDate(value)).toBe("—"); + expect(formatTime(value)).toBe("—"); + expect(timeAgo(value)).toBe("—"); + } + ); + + it("formats recent and older relative times", () => { + const now = Math.floor(Date.now() / 1_000); + expect(timeAgo(now - 2, "en-US")).toBe("just now"); + expect(timeAgo(now - 2 * 3_600, "en-US")).toBe("2 hours ago"); + expect(timeAgo((now + 5 * 60) * 1_000, "en-US")).toBe("in 5 minutes"); + }); - expect(formatTime(timestamp, "en-US", options)).toBe( - new Intl.DateTimeFormat("en-US", { - hour: "2-digit", - minute: "2-digit", - timeZone: "UTC", - }).format(new Date(timestamp * 1000)) + it.each([ + [-6, "second", -6], + [6, "second", 6], + [-90, "minute", -2], + [90, "minute", 2], + [-2 * 3_600, "hour", -2], + [2 * 86_400, "day", 2], + [-2 * 604_800, "week", -2], + [2 * 2_592_000, "month", 2], + [-2 * 31_536_000, "year", -2], + ] as const)( + "formats a relative timestamp offset by %s seconds", + (offsetSeconds, unit, value) => { + const now = Math.floor(Date.now() / 1_000); + const expected = new Intl.RelativeTimeFormat("en-US", { + numeric: "auto", + }).format(value, unit); + + expect(timeAgo((now + offsetSeconds) * 1_000, "en-US")).toBe(expected); + } + ); + + it("formats time remaining from Unix seconds", () => { + const now = Math.floor(Date.now() / 1_000); + expect(timeUntil(now - 1)).toBe("Ended"); + expect(timeUntil(now + 2 * 86_400 + 3 * 3_600 + 45 * 60)).toBe( + "2d 3h 45m" ); + expect(timeUntil(now + 30)).toBe("30s"); }); -}); -describe("calculatePayout", () => { - it("calculates correct payout for sole winner (100% of winning side)", () => { - // User bet 100, winning side total 100, pool 300 → gets entire pool - expect(calculatePayout(100, 100, 300)).toBe(300); + it.each([ + [0, "Ended"], + [59, "59s"], + [60, "1m"], + [3_599, "59m"], + [3_600, "1h 0m"], + [86_399, "23h 59m"], + [86_400, "1d 0h 0m"], + ])("formats a countdown boundary offset by %s seconds", (offset, expected) => { + const now = Math.floor(Date.now() / 1_000); + expect(timeUntil(now + offset)).toBe(expected); }); +}); - it("calculates proportional payout for multiple winners", () => { - // User bet 50, winning side total 200, pool 500 +describe("market calculations", () => { + it("calculates proportional payouts", () => { expect(calculatePayout(50, 200, 500)).toBe(125); - }); - - it("calculates equal split payout", () => { - // 2 equal winners: user bet 50, winning side 100, pool 200 - expect(calculatePayout(50, 100, 200)).toBe(100); - }); - - it("returns 0 if winning side total is 0", () => { expect(calculatePayout(100, 0, 500)).toBe(0); }); - it("returns 0 if winning side total is negative", () => { - expect(calculatePayout(100, -1, 500)).toBe(0); - }); - - it("handles small fractional bets", () => { - // User bet 1, winning side 3, pool 10 → ~3.333 - const payout = calculatePayout(1, 3, 10); - expect(payout).toBeCloseTo(3.333, 2); - }); -}); - -// ── calculateOdds ───────────────────────────────────────────────────────────── - -describe("calculateOdds", () => { - it("returns 50/50 when no bets", () => { + it("calculates odds that always total 100", () => { expect(calculateOdds(0, 0)).toEqual({ yesPercent: 50, noPercent: 50 }); + expect(calculateOdds(1, 2)).toEqual({ yesPercent: 33, noPercent: 67 }); }); - it("returns correct percentages for clear split", () => { - expect(calculateOdds(75, 25)).toEqual({ yesPercent: 75, noPercent: 25 }); - }); - - it("returns 100/0 when all bets on YES", () => { - expect(calculateOdds(500, 0)).toEqual({ yesPercent: 100, noPercent: 0 }); - }); - - it("returns 0/100 when all bets on NO", () => { - expect(calculateOdds(0, 300)).toEqual({ yesPercent: 0, noPercent: 100 }); - }); - - it("rounds percentages and always totals 100", () => { - const result = calculateOdds(1, 2); - expect(result.yesPercent + result.noPercent).toBe(100); - expect(result.yesPercent).toBe(33); - expect(result.noPercent).toBe(67); - }); -}); - -// ── bpsToPercent ────────────────────────────────────────────────────────────── - -describe("bpsToPercent", () => { - it("converts 200 bps to 2%", () => { + it("converts basis points", () => { expect(bpsToPercent(200)).toBe("2%"); - }); - - it("converts 150 bps to 1.5%", () => { expect(bpsToPercent(150)).toBe("1.5%"); }); - it("converts 50 bps to 0.5%", () => { - expect(bpsToPercent(50)).toBe("0.5%"); - }); - - it("converts 10000 bps to 100%", () => { - expect(bpsToPercent(10000)).toBe("100%"); - }); - - it("returns a relative string for a timestamp ~2 hours ago", () => { - vi.useFakeTimers(); - const now = 1_700_000_000; - vi.setSystemTime(now * 1000); - const result = timeAgo(now - 7200); // 2 hours ago - // Should contain "2" and "hour" in some locale-appropriate form - expect(result).toMatch(/2/); - expect(result.toLowerCase()).toMatch(/hour/); - }); - - it("returns a relative string for a timestamp ~3 days ago", () => { - vi.useFakeTimers(); - const now = 1_700_000_000; - vi.setSystemTime(now * 1000); - const result = timeAgo(now - 3 * 86400); - expect(result).toMatch(/3/); - expect(result.toLowerCase()).toMatch(/day/); - }); - - it("accepts millisecond timestamps without producing a future time", () => { - vi.useFakeTimers(); - const now = 1_700_000_000; - vi.setSystemTime(now * 1000); - // Pass ms timestamp for same instant — should still be "just now", not future - const result = timeAgo(now * 1000); - expect(result).not.toMatch(/in /i); // Intl.RelativeTimeFormat future prefix - }); - - it("uses Intl.RelativeTimeFormat with undefined locale", () => { - vi.useFakeTimers(); - const now = 1_700_000_000; - vi.setSystemTime(now * 1000); - const spy = vi.spyOn(Intl, "RelativeTimeFormat"); - timeAgo(now - 3600); - expect(spy).toHaveBeenCalledWith(undefined, expect.any(Object)); - spy.mockRestore(); - }); -}); - -// ── formatDate ──────────────────────────────────────────────────────────────── - -describe("formatDate", () => { - it("formats a valid unix timestamp (seconds)", () => { - const result = formatDate(1771977600); - expect(result).toContain("2026"); - }); - - it("returns an em dash for invalid input", () => { - expect(formatDate(0)).toBe("—"); - expect(formatDate(NaN)).toBe("—"); - expect(formatDate(-1)).toBe("—"); - }); -}); - -// ── formatEventTime ─────────────────────────────────────────────────────────── - -describe("formatEventTime", () => { - it("renders a millisecond timestamp correctly", () => { - const ms = 1720872000000; - const result = formatEventTime(ms); - expect(result).toContain("2024"); - }); - - it("returns an em dash for invalid input", () => { - expect(formatEventTime(0)).toBe("—"); - expect(formatEventTime(NaN)).toBe("—"); - expect(formatEventTime(-1)).toBe("—"); - }); -}); - -// ── timeAgo ──────────────────────────────────────────────────────────────────── - -describe("timeAgo", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-07-13T12:00:00Z")); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("returns 'just now' for a timestamp within 5 seconds", () => { - const now = Math.floor(Date.now() / 1000); - expect(timeAgo(now - 2)).toBe("just now"); - }); - - it("returns a relative string for past timestamps", () => { - const now = Math.floor(Date.now() / 1000); - const result = timeAgo(now - 5 * 60); - expect(result).toMatch(/\d/); - }); - - it("returns an em dash for invalid input", () => { - expect(timeAgo(0)).toBe("—"); - expect(timeAgo(NaN)).toBe("—"); - }); -}); - -// ── formatDate ──────────────────────────────────────────────────────────────── - -describe("formatDate", () => { - it("formats a valid timestamp", () => { - const result = formatDate(1771977600); - expect(result).toContain("2026"); - }); -}); - -// ── formatEventTime ─────────────────────────────────────────────────────────── - -describe("formatEventTime", () => { - it("renders a millisecond timestamp correctly", () => { - const ms = 1720872000000; // 2026-07-13T12:00:00Z (example) - const result = formatEventTime(ms); - expect(result).toContain("2026"); - }); - it("returns an em dash for invalid input", () => { - expect(formatEventTime(0)).toBe("—"); - expect(formatEventTime(NaN)).toBe("—"); - expect(formatEventTime(-1)).toBe("—"); - }); -}); - -// ── timeAgo ──────────────────────────────────────────────────────────────────── - -describe("timeAgo", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-07-13T12:00:00Z")); - }); - afterEach(() => { - vi.useRealTimers(); - }); - it("returns 'just now' for a timestamp within 5 seconds", () => { - const now = Math.floor(Date.now() / 1000); - expect(timeAgo(now - 2)).toBe("just now"); - }); - it("returns a relative string like '5 minutes ago'", () => { - const now = Math.floor(Date.now() / 1000); - expect(timeAgo(now - 5 * 60)).toMatch(/5.*minute/); - }); - it("returns an em dash for invalid input", () => { - expect(timeAgo(0)).toBe("—"); - expect(timeAgo(NaN)).toBe("—"); - }); -}); - - -// ── formatDate / formatTime / toTimestampMs ─────────────────────────────────── - -describe("toTimestampMs", () => { - it("treats second-precision values as seconds", () => { - expect(toTimestampMs(1_700_000_000)).toBe(1_700_000_000_000); - }); - - it("leaves millisecond values unchanged", () => { - expect(toTimestampMs(1_700_000_000_000)).toBe(1_700_000_000_000); - }); -}); - -describe("formatDate", () => { - it("formats second timestamps without year corruption", () => { - const s = formatDate(1_700_000_000, "en-US"); - expect(s).toMatch(/2023/); - expect(s).not.toMatch(/5\d{4}/); - }); - - it("formats millisecond event timestamps the same way", () => { - const s = formatDate(1_700_000_000_000, "en-US"); - expect(s).toMatch(/2023/); - }); -}); - -describe("formatTime", () => { - it("returns a locale time string with timezone abbreviation", () => { - const s = formatTime(1_700_000_000_000, "en-US"); - expect(s.length).toBeGreaterThan(3); + it("builds Stellar Expert links", () => { + expect(explorerUrl("tx", "abc123")).toBe( + "https://stellar.expert/explorer/public/tx/abc123" + ); + expect(explorerUrl("contract", "CDEF", "testnet")).toBe( + "https://stellar.expert/explorer/testnet/contract/CDEF" + ); }); }); diff --git a/frontend/src/__tests__/useLeaderboard.test.tsx b/frontend/src/__tests__/useLeaderboard.test.tsx index ef1a723..68288c0 100644 --- a/frontend/src/__tests__/useLeaderboard.test.tsx +++ b/frontend/src/__tests__/useLeaderboard.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, renderHook, waitFor } from "@testing-library/react"; +import { renderHook, waitFor } from "@testing-library/react"; const mocks = vi.hoisted(() => ({ getTopPlayers: vi.fn(), @@ -36,16 +36,6 @@ vi.mock("@/hooks/useVisiblePoll", () => ({ import { useLeaderboard } from "@/hooks/useLeaderboard"; describe("useLeaderboard lastUpdated", () => { - const player = { - address: "GALICE", - displayName: "Alice", - points: 100, - totalBets: 4, - wonBets: 3, - lostBets: 1, - winRate: 75, - }; - beforeEach(() => { vi.clearAllMocks(); mocks.getMarkets.mockResolvedValue([]); @@ -55,42 +45,49 @@ describe("useLeaderboard lastUpdated", () => { vi.restoreAllMocks(); }); - it("records Unix seconds only after a successful refresh", async () => { + it("records Unix seconds after a successful refresh", async () => { const refreshedAtMs = 1_800_000_000_123; vi.spyOn(Date, "now").mockReturnValue(refreshedAtMs); - mocks.getTopPlayers.mockResolvedValue([player]); + 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.lastUpdated).toBe(Math.floor(refreshedAtMs / 1000)); + expect(result.current.data).toHaveLength(1); + expect(result.current.lastUpdated).toBe(Math.floor(refreshedAtMs / 1_000)); }); - it("keeps the previous timestamp when services return no refreshed data", async () => { - const now = vi.spyOn(Date, "now").mockReturnValue(1_800_000_000_000); - mocks.getTopPlayers.mockResolvedValueOnce([player]); + it("timestamps a successful empty refresh", async () => { + const refreshedAtMs = 1_900_000_000_456; + vi.spyOn(Date, "now").mockReturnValue(refreshedAtMs); + mocks.getTopPlayers.mockResolvedValue([]); const { result } = renderHook(() => useLeaderboard("top_predictors")); await waitFor(() => expect(result.current.loading).toBe(false)); - const successfulRefresh = result.current.lastUpdated; - expect(successfulRefresh).not.toBeNull(); - - now.mockReturnValue(1_900_000_000_000); - let resolveRefresh!: (players: never[]) => void; - const emptyRefresh = new Promise((resolve) => { - resolveRefresh = resolve; - }); - mocks.getTopPlayers.mockReturnValueOnce(emptyRefresh); - - act(() => result.current.refetch()); - await act(async () => { - resolveRefresh([]); - await emptyRefresh; - }); + expect(result.current.data).toEqual([]); + expect(result.current.lastUpdated).toBe(Math.floor(refreshedAtMs / 1_000)); + }); + + 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.lastUpdated).toBe(successfulRefresh); + + 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 dc601d6..111ea40 100644 --- a/frontend/src/app/leaderboard/page.tsx +++ b/frontend/src/app/leaderboard/page.tsx @@ -1,103 +1,67 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { useLeaderboard, type LeaderboardTab } from "@/hooks/useLeaderboard"; import { useWallet } from "@/hooks/useWallet"; import LeaderboardTabs from "@/components/leaderboard/LeaderboardTabs"; +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 { FiAward } from "react-icons/fi"; -import { timeAgo } from "@/utils/helpers"; 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, lastUpdated } = useLeaderboard(tab); const { publicKey } = useWallet(); - const [lastUpdated, setLastUpdated] = useState( - Math.floor(Date.now() / 1000) - ); - const [, forceUpdate] = useState(0); - - // Record when data last loaded - useEffect(() => { - if (!loading) { - setLastUpdated(Math.floor(Date.now() / 1000)); - } - }, [loading, tab]); - - // Re-render every 30s so the "X ago" string stays fresh - useEffect(() => { - const id = setInterval(() => forceUpdate((n) => n + 1), 30_000); - return () => clearInterval(id); - }, []); return (
-
-

- - Leaderboard -

-
- - - - - +
+
+

+ Leaderboard +

+ + Live
-
- -
-

- Rankings update in real-time from onchain data. Timestamps across the app use your local timezone. -

- {!loading && ( -

- Updated {timeAgo(lastUpdated)} -

- )} -
+

Rankings update in real-time from onchain data.

- {!loading && ( + {lastUpdated && (

- Updated {timeAgo(lastUpdated)} + Last updated: {formatDate(lastUpdated)}

)}
- - - {lastUpdated && ( -
- Last updated: {formatDate(lastUpdated)} -
- )} +
+ setTab(nextTab as LeaderboardTab)} + /> +
- {/* Content */} {loading ? ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ {Array.from({ length: 8 }).map((_, index) => ( +
+ + + + +
+ ))}
) : error ? (
@@ -106,101 +70,19 @@ export default function LeaderboardPage() {
) : players.length === 0 ? ( ) : ( -
-
- - - - - - - - - - - - {players.map((player, index) => { - const isCurrentUser = player.address === publicKey; - return ( - - - - - - - - ); - })} - -
- Rank - - Player - - Points - - Win Rate - - Bets -
- - {index + 1} - - -
- - {player.displayName || truncateAddress(player.address)} - - {isCurrentUser && ( - - You - - )} -
-
- {player.points.toLocaleString()} - - = 50 - ? "text-accent-mint" - : "text-accent-red" - } - > - {player.winRate}% - - - {player.totalBets} -
-
+
+
)}
); } - -// Helper function used inside the component -function truncateAddress(addr: string): string { - if (!addr || addr.length <= 10) return addr; - return `${addr.slice(0, 4)}...${addr.slice(-4)}`; -} diff --git a/frontend/src/app/markets/[id]/page.tsx b/frontend/src/app/markets/[id]/page.tsx index 858aefc..8c2c816 100644 --- a/frontend/src/app/markets/[id]/page.tsx +++ b/frontend/src/app/markets/[id]/page.tsx @@ -14,10 +14,6 @@ import { calculatePayout, truncateAddress, } from "@/utils/helpers"; -import { displayXLM, formatXLM, calculatePayout, truncateAddress, formatTime} from "@/utils/helpers"; -import { displayXLM, formatXLM, calculatePayout, truncateAddress, formatTime } from "@/utils/helpers"; -import { displayXLM, formatXLM, calculatePayout, truncateAddress, formatEventTime } from "@/utils/helpers"; -import { displayXLM, formatTime, formatXLM, calculatePayout, truncateAddress } from "@/utils/helpers"; import { WIN_POINTS, LOSE_POINTS, @@ -34,12 +30,6 @@ import TxProgress from "@/components/ui/TxProgress"; import ErrorBoundary from "@/components/ui/ErrorBoundary"; import Button from "@/components/ui/Button"; import type { MarketEvent } from "@/types"; -import { - calculatePayout, - displayXLM, - formatXLM, - truncateAddress, -} from "@/utils/helpers"; import { FiClock, FiUsers, FiTrendingUp, FiAward, FiArrowLeft } from "react-icons/fi"; import Link from "next/link"; @@ -335,7 +325,6 @@ export default function MarketDetailPage({
- {formatEventTime(evt.timestamp)} {formatTime(evt.timestamp)}
diff --git a/frontend/src/hooks/useLeaderboard.ts b/frontend/src/hooks/useLeaderboard.ts index 948de4e..e20e517 100644 --- a/frontend/src/hooks/useLeaderboard.ts +++ b/frontend/src/hooks/useLeaderboard.ts @@ -155,9 +155,7 @@ export function useLeaderboard( // Persist assembled leaderboard for instant stale-seed next time cache.set(LB_CACHE_KEY, players, 60_000); setAllPlayers(players); - if (players.length > 0) { - setLastUpdated(Math.floor(Date.now() / 1000)); - } + setLastUpdated(Math.floor(Date.now() / 1000)); } catch (err) { if (!mountedRef.current) return; setError( diff --git a/frontend/src/services/events.ts b/frontend/src/services/events.ts index b56b4c5..db66367 100644 --- a/frontend/src/services/events.ts +++ b/frontend/src/services/events.ts @@ -3,8 +3,6 @@ import { MARKET_CONTRACT_ID } from "@/config/network"; import { getSorobanServer } from "@/services/soroban"; import type { MarketEvent } from "@/types"; -// ── Event type names emitted by the PredictionMarket contract ───────────────── - const EVENT_TYPES = [ "bet_placed", "market_resolved", @@ -12,123 +10,86 @@ const EVENT_TYPES = [ "reward_claimed", "fees_withdrawn", ] as const; +const MILLISECOND_TIMESTAMP_THRESHOLD = 100_000_000_000; +const ISO_TIMESTAMP_WITH_TIMEZONE = + /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/; type ContractEventType = (typeof EVENT_TYPES)[number]; -function isKnownEventType(s: string): s is ContractEventType { - return (EVENT_TYPES as readonly string[]).includes(s); +function isKnownEventType(value: string): value is ContractEventType { + return (EVENT_TYPES as readonly string[]).includes(value); } -export function ledgerClosedAtToUnixSeconds( - ledgerClosedAt: string | number | Date -): number { - if (typeof ledgerClosedAt === "number") { - // Soroban timestamps are Unix seconds; tolerate millisecond values from - // callers that already normalized the API response to a number. - return Math.floor( - ledgerClosedAt >= 1_000_000_000_000 - ? ledgerClosedAt / 1000 - : ledgerClosedAt - ); - } - - return Math.floor(new Date(ledgerClosedAt).getTime() / 1000); -/** 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)) { +function assertValidIsoTimestamp(value: string): void { + const match = ISO_TIMESTAMP_WITH_TIMEZONE.exec(value); + if (!match) throw new RangeError("Invalid ledger close timestamp"); + + const [, yearValue, monthValue, dayValue, hourValue, minuteValue, secondValue, , zone] = + match; + const year = Number(yearValue); + const month = Number(monthValue); + const day = Number(dayValue); + const hour = Number(hourValue); + const minute = Number(minuteValue); + const second = Number(secondValue); + const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate(); + + if ( + month < 1 || + month > 12 || + day < 1 || + day > daysInMonth || + hour > 23 || + minute > 59 || + second > 59 + ) { 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}))$/; + if (zone !== "Z") { + const offsetHour = Number(zone.slice(1, 3)); + const offsetMinute = Number(zone.slice(4, 6)); + if (offsetHour > 23 || offsetMinute > 59) { + throw new RangeError("Invalid ledger close timestamp"); + } + } +} -/** Normalize an RPC ledger close time to the app's Unix-seconds contract. */ +/** Normalize Stellar ledger-close values to the app's Unix-seconds contract. */ export function ledgerClosedAtToUnixSeconds( ledgerClosedAt: string | number | Date ): number { - let timestampSeconds: number; + let timestampMs: number; if (typeof ledgerClosedAt === "number") { - timestampSeconds = - Math.abs(ledgerClosedAt) >= MILLISECOND_TIMESTAMP_THRESHOLD - ? ledgerClosedAt / 1000 + if (!Number.isFinite(ledgerClosedAt) || ledgerClosedAt <= 0) { + throw new RangeError("Invalid ledger close timestamp"); + } + timestampMs = + Math.abs(ledgerClosedAt) < MILLISECOND_TIMESTAMP_THRESHOLD + ? ledgerClosedAt * 1_000 : ledgerClosedAt; } else if (ledgerClosedAt instanceof Date) { - timestampSeconds = ledgerClosedAt.getTime() / 1000; + timestampMs = ledgerClosedAt.getTime(); + } else if (typeof ledgerClosedAt === "string") { + const value = ledgerClosedAt.trim(); + assertValidIsoTimestamp(value); + timestampMs = Date.parse(value); } else { - const timestampText = ledgerClosedAt.trim(); - const match = ISO_TIMESTAMP_WITH_TIME_ZONE.exec(timestampText); - if (!match) throw new RangeError("Invalid ledger close timestamp"); - - const [, yearText, monthText, dayText, hourText, minuteText, secondText] = - match; - const year = Number(yearText); - const month = Number(monthText); - const day = Number(dayText); - const hour = Number(hourText); - const minute = Number(minuteText); - const second = Number(secondText); - const offsetHour = Number(match[7] ?? 0); - const offsetMinute = Number(match[8] ?? 0); - const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); - const daysInMonth = [ - 31, - leapYear ? 29 : 28, - 31, - 30, - 31, - 30, - 31, - 31, - 30, - 31, - 30, - 31, - ]; - - if ( - month < 1 || - month > 12 || - day < 1 || - day > daysInMonth[month - 1] || - hour > 23 || - minute > 59 || - second > 59 || - offsetHour > 23 || - offsetMinute > 59 - ) { - throw new RangeError("Invalid ledger close timestamp"); - } - - timestampSeconds = Date.parse(timestampText) / 1000; + throw new RangeError("Invalid ledger close timestamp"); } - if (!Number.isFinite(timestampSeconds)) { + if (!Number.isFinite(timestampMs) || timestampMs <= 0) { throw new RangeError("Invalid ledger close timestamp"); } - return Math.floor(timestampSeconds); + return Math.floor(timestampMs / 1_000); } -// ── Parse a single event response into MarketEvent ──────────────────────────── - -function parseEventResponse( - event: rpc.Api.EventResponse -): MarketEvent | null { +function parseEventResponse(event: rpc.Api.EventResponse): MarketEvent | null { try { - // Topics: [event_name, ...params] - const topics = event.topic.map((t: xdr.ScVal) => scValToNative(t)); + const topics = event.topic.map((topic: xdr.ScVal) => scValToNative(topic)); const eventName = String(topics[0]); - if (!isKnownEventType(eventName)) return null; const data = scValToNative(event.value); @@ -144,16 +105,14 @@ function parseEventResponse( timestamp, txHash: event.txHash, }; - case "market_resolved": return { type: "market_resolved", marketId: Number(topics[1] ?? data?.market_id ?? 0), - user: "", // resolved by admin, no specific user + user: "", timestamp, txHash: event.txHash, }; - case "market_cancelled": return { type: "market_cancelled", @@ -162,7 +121,6 @@ function parseEventResponse( timestamp, txHash: event.txHash, }; - case "reward_claimed": return { type: "reward_claimed", @@ -172,7 +130,6 @@ function parseEventResponse( timestamp, txHash: event.txHash, }; - case "fees_withdrawn": return { type: "fees_withdrawn", @@ -182,7 +139,6 @@ function parseEventResponse( timestamp, txHash: event.txHash, }; - default: return null; } @@ -191,24 +147,11 @@ function parseEventResponse( } } -// ── Public API ──────────────────────────────────────────────────────────────── - -/** - * Poll for market events starting from a given ledger sequence. - * Parses bet_placed, market_resolved, reward_claimed, market_cancelled, - * and fees_withdrawn events from the PredictionMarket contract. - * - * @param startLedger — Ledger sequence to start from. If omitted, fetches - * from ~5 minutes ago (approx 60 ledgers back at 5s/ledger). - * @returns Array of parsed MarketEvent objects, newest first. - */ -export async function pollMarketEvents( - startLedger?: number -): Promise { +/** Poll market events and return the newest valid entries first. */ +export async function pollMarketEvents(startLedger?: number): Promise { const server = getSorobanServer(); try { - // Default to ~60 ledgers back if no start specified let ledger = startLedger; if (!ledger) { const latest = await server.getLatestLedger(); @@ -221,7 +164,7 @@ export async function pollMarketEvents( { type: "contract", contractIds: [MARKET_CONTRACT_ID], - topics: [["*"]], // match all topics from this contract + topics: [["*"]], }, ], limit: 100, @@ -232,8 +175,6 @@ export async function pollMarketEvents( const parsed = parseEventResponse(raw); if (parsed) events.push(parsed); } - - // Return newest first return events.reverse(); } catch { return []; diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index 2eeba6e..f80890e 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -1,6 +1,11 @@ +// ── Pure Utility Functions ─────────────────────────────────────────────────── + const STROOPS_PER_XLM = 10_000_000n; -const DASH = "—"; +const MILLISECOND_TIMESTAMP_THRESHOLD = 100_000_000_000; +const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000; +const INVALID_TIMESTAMP = "—"; +/** Convert stroops (bigint) to a human-readable XLM string. */ export function formatXLM(stroops: bigint): string { const isNegative = stroops < 0n; const abs = isNegative ? -stroops : stroops; @@ -9,334 +14,128 @@ export function formatXLM(stroops: bigint): string { const fracStr = fractional.toString().padStart(7, "0").replace(/0+$/, ""); const sign = isNegative ? "-" : ""; - if (fracStr.length === 0) { - return `${sign}${whole} XLM`; - } - return `${sign}${whole}.${fracStr} XLM`; + return fracStr.length === 0 + ? `${sign}${whole} XLM` + : `${sign}${whole}.${fracStr} XLM`; } +/** Format a number that is already expressed in XLM. */ export function displayXLM(xlm: number): string { if (xlm === 0) return "0 XLM"; const formatted = xlm.toFixed(2).replace(/\.?0+$/, ""); return `${formatted} XLM`; } +/** Truncate a Stellar address for display. */ export function truncateAddress(addr: string): string { if (!addr || addr.length <= 10) return addr; return `${addr.slice(0, 4)}...${addr.slice(-4)}`; } +/** Validate a bet amount against the minimum and the user's balance. */ export function isValidAmount(amount: string, balance: number): boolean { const parsed = parseFloat(amount); - if (isNaN(parsed) || parsed < 1) return false; + if (Number.isNaN(parsed) || parsed < 1) return false; return parsed <= balance; } -/** - * Return a human-readable "time until" string from a Unix timestamp (seconds). - * Example: timestamp 2 days from now → "2d 14h 32m" - */ +/** Return a human-readable duration until a Unix-seconds timestamp. */ export function timeUntil(timestamp: number): string { const now = Math.floor(Date.now() / 1000); const diff = timestamp - now; if (diff <= 0) return "Ended"; - const days = Math.floor(diff / 86400); - const hours = Math.floor((diff % 86400) / 3600); - const minutes = Math.floor((diff % 3600) / 60); + const days = Math.floor(diff / 86_400); + const hours = Math.floor((diff % 86_400) / 3_600); + const minutes = Math.floor((diff % 3_600) / 60); if (days > 0) return `${days}d ${hours}h ${minutes}m`; if (hours > 0) return `${hours}h ${minutes}m`; if (minutes > 0) return `${minutes}m`; - 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)); -/** - * Normalize a Unix timestamp that may be seconds or milliseconds to ms. - * Event feeds currently emit ms (Date#getTime); some contract fields use seconds. - */ +/** Normalize a positive Unix timestamp supplied in seconds or milliseconds. */ export function toTimestampMs(timestamp: number): number { - if (!Number.isFinite(timestamp)) return Date.now(); - // Values below ~1e12 are almost certainly seconds (year ~2001 in ms would be ~1e12). - return timestamp < 1e12 ? timestamp * 1000 : timestamp; -} - -const DATE_TIME_OPTS: Intl.DateTimeFormatOptions = { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", -}; - -/** - * Format a Unix timestamp (seconds or ms) to the viewer's locale + timezone. - */ -export function formatDate(timestamp: number, locale?: string): string { - const d = new Date(toTimestampMs(timestamp)); - return d.toLocaleString(locale, DATE_TIME_OPTS); + if (!Number.isFinite(timestamp) || timestamp <= 0) return Number.NaN; + const timestampMs = + timestamp < MILLISECOND_TIMESTAMP_THRESHOLD + ? timestamp * 1_000 + : timestamp; + return timestampMs <= MAX_DATE_TIMESTAMP_MS ? timestampMs : Number.NaN; } -/** - * Shared date/time options keep timestamp display consistent while letting the - * browser choose the user's locale and time zone by default. - */ -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 a Unix timestamp to the user's locale and time zone. - */ +/** Format a timestamp in the viewer's timezone, including its timezone label. */ export function formatDate( timestamp: number, locale?: Intl.LocalesArgument, - options?: Intl.DateTimeFormatOptions + options: Intl.DateTimeFormatOptions = {} ): string { - return new Intl.DateTimeFormat(locale, { - ...DATE_TIME_OPTIONS, - ...options, - }).format(new Date(timestamp * 1000)); -} + const timestampMs = toTimestampMs(timestamp); + if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP; -/** - * Format a Unix timestamp to a compact user-local time. - */ -export function formatTime( - timestamp: number, - locale?: Intl.LocalesArgument, - options?: Intl.DateTimeFormatOptions -): string { return new Intl.DateTimeFormat(locale, { - ...TIME_OPTIONS, - ...options, - }).format(new Date(timestamp * 1000)); - * Format time-of-day only, using the viewer's locale + timezone. - */ -export function formatTime(timestamp: number, locale?: string): string { - const d = new Date(toTimestampMs(timestamp)); - return d.toLocaleTimeString(locale, { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - * Format a Unix timestamp to a locale-aware date string. - * Uses the user's locale and timezone for consistent display across views. - */ -export function formatDate(timestamp: number): string { - return new Date(timestamp * 1000).toLocaleString(undefined, { - * Format a Unix timestamp (seconds) to a locale-aware date/time string. - * - * Uses the viewer's browser locale and local timezone automatically — no - * hardcoded "en-US" or UTC offset. Timestamps are treated as Unix seconds - * and converted to milliseconds before constructing the Date. - * - * Example (en-GB, Europe/London): "12 Jul 2026, 14:30" - * Example (en-US, America/New_York): "Jul 12, 2026, 10:30 AM" - */ -export function formatDate(timestamp: number): string { - if (!Number.isFinite(timestamp) || timestamp <= 0) return "—"; - // Guard against accidental millisecond values (if timestamp > year 2100 in seconds) - const ms = timestamp > 4_102_444_800 ? timestamp : timestamp * 1000; - return new Date(ms).toLocaleString(undefined, { - return new Date(timestamp * 1000).toLocaleDateString(undefined, { - // Soroban ledger timestamps are Unix seconds. Guard against accidental - // millisecond values (> year 2100 in seconds ≈ 4_102_444_800). - const ms = timestamp > 4_102_444_800 ? timestamp : timestamp * 1000; - - return new Date(ms).toLocaleString(undefined, { -function isValidTimestamp(timestamp: number): boolean { - return Number.isFinite(timestamp) && timestamp > 0; -} year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short", - }); + ...options, + }).format(new Date(timestampMs)); } -/** - * Format an event timestamp (milliseconds) to a locale-aware date+time string. - * Use this for `MarketEvent.timestamp` – it is already in milliseconds, do NOT multiply by 1000. - */ -export function formatEventTime(timestampMs: number): string { - if (!Number.isFinite(timestampMs) || timestampMs <= 0) return "—"; - return new Date(timestampMs).toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - }); -} +/** Format only the local time portion of a timestamp, with its timezone label. */ +export function formatTime( + timestamp: number, + locale?: Intl.LocalesArgument, + options: Intl.DateTimeFormatOptions = {} +): string { + const timestampMs = toTimestampMs(timestamp); + if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP; -/** - * Format a Unix timestamp to a locale-aware time-only string. - * Uses the user's locale and timezone for consistent display across views. - */ -export function formatTime(timestamp: number): string { - return new Date(timestamp * 1000).toLocaleTimeString(undefined, { + return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", timeZoneName: "short", - }); + ...options, + }).format(new Date(timestampMs)); } -/** - * Return a human-readable relative time string from a Unix timestamp (seconds). - * Example: "2 hours ago", "just now" - */ -export function timeAgo(timestampSec: number): string { - if (!Number.isFinite(timestampSec) || timestampSec <= 0) return "—"; - // Guard against millisecond values - const ms = timestampSec > 4_102_444_800 ? timestampSec : timestampSec * 1000; - const diffSeconds = Math.floor((Date.now() - ms) / 1000); - if (diffSeconds < 5) return "just now"; - const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); - const intervals: [Intl.RelativeTimeFormatUnit, number][] = [ +/** Format a timestamp relative to now while accepting seconds or milliseconds. */ +export function timeAgo( + timestamp: number, + locale?: Intl.LocalesArgument +): string { + const timestampMs = toTimestampMs(timestamp); + if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP; + + const diffSeconds = (timestampMs - Date.now()) / 1_000; + const absoluteSeconds = Math.abs(diffSeconds); + if (absoluteSeconds < 5) return "just now"; + + const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [ ["year", 31_536_000], ["month", 2_592_000], + ["week", 604_800], ["day", 86_400], ["hour", 3_600], ["minute", 60], ["second", 1], ]; - for (const [unit, secondsInUnit] of intervals) { - if (Math.abs(diffSeconds) >= secondsInUnit || unit === "second") { - const value = Math.round(diffSeconds / secondsInUnit); - return rtf.format(value, unit); - } - } - return rtf.format(0, "second"); - * Return a human-readable relative time string from a Unix timestamp (seconds). - * Automatically uses the viewer's locale via Intl.RelativeTimeFormat. - * - * Examples: "2 hours ago", "3 days ago", "just now" - */ -export function timeAgo(timestamp: number): string { - // Same millisecond guard as formatDate - const ms = timestamp > 4_102_444_800 ? timestamp : timestamp * 1000; - const diffSeconds = Math.floor((Date.now() - ms) / 1000); - - if (diffSeconds < 5) return "just now"; - - const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); - - const thresholds: [number, Intl.RelativeTimeFormatUnit][] = [ - [60, "second"], - [3_600, "minute"], - [86_400, "hour"], - [604_800, "day"], - [2_592_000, "week"], - [31_536_000, "month"], - ]; - - for (const [limit, unit] of thresholds) { - if (diffSeconds < limit) { - const prev = thresholds[thresholds.indexOf([limit, unit]) - 1]; - const divisor = prev ? prev[0] : 1; - return rtf.format(-Math.floor(diffSeconds / divisor), unit); - } - } - - return rtf.format(-Math.floor(diffSeconds / 31_536_000), "year"); -} - -/** - * Calculate a winner's payout from a prediction market. - * - * payout = (userNetBet / winningSideTotal) × totalPool - * - * All values in XLM (not stroops). - * Format a Unix timestamp to a viewer-locale time string. - */ -export function formatTime(timestamp: number): string { - return new Date(timestampToMilliseconds(timestamp)).toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - ...options, - }); -} - -export function formatDate( - timestamp: number, - locale?: Intl.LocalesArgument, - options: Intl.DateTimeFormatOptions = {} -): string { - return formatTimestamp(timestamp, locale, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - ...options, - }); -} + const [unit, unitSeconds] = + units.find(([, seconds]) => absoluteSeconds >= seconds) ?? units[6]; -export function formatTime( - timestamp: number, - locale?: Intl.LocalesArgument, - options: Intl.DateTimeFormatOptions = {} -): string { - return formatTimestamp(timestamp, locale, { - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - ...options, - }); + const value = + Math.sign(diffSeconds) * Math.round(absoluteSeconds / unitSeconds); + return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format( + value, + unit + ); } +/** Calculate a winner's payout from a prediction market. */ export function calculatePayout( userNetBet: number, winningSideTotal: number, @@ -346,30 +145,38 @@ export function calculatePayout( return (userNetBet / winningSideTotal) * totalPool; } -/** - * Calculate YES/NO odds percentages from net totals. - * Returns { yesPercent, noPercent } – each 0-100. - */ +/** Calculate YES/NO odds percentages from net totals. */ export function calculateOdds( - yesTotal: number, - noTotal: number + totalYes: number, + totalNo: number ): { yesPercent: number; noPercent: number } { - const total = yesTotal + noTotal; - if (total === 0) return { yesPercent: 50, noPercent: 50 }; - const yesPercent = Math.round((yesTotal / total) * 100); + const total = totalYes + totalNo; + if (total <= 0) return { yesPercent: 50, noPercent: 50 }; + + const yesPercent = Math.round((totalYes / total) * 100); return { yesPercent, noPercent: 100 - yesPercent }; } +/** Convert basis points to a percentage string. */ +export function bpsToPercent(bps: number): string { + return `${bps / 100}%`; +} + +/** Build a Stellar Expert explorer URL. */ export function explorerUrl( type: "tx" | "account" | "contract", id: string, network: "public" | "testnet" = "public" ): string { - if (minutes < 60) return `${minutes}m ago`; - - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - - const days = Math.floor(hours / 24); - return `${days}d ago`; + const base = `https://stellar.expert/explorer/${network}`; + switch (type) { + case "tx": + return `${base}/tx/${id}`; + case "account": + return `${base}/account/${id}`; + case "contract": + return `${base}/contract/${id}`; + default: + return base; + } }