diff --git a/frontend/src/__tests__/helpers.test.ts b/frontend/src/__tests__/helpers.test.ts index efc1bb4..b1137a0 100644 --- a/frontend/src/__tests__/helpers.test.ts +++ b/frontend/src/__tests__/helpers.test.ts @@ -1,251 +1,385 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { - bpsToPercent, - calculateOdds, - calculatePayout, - displayXLM, - explorerUrl, - formatDate, - formatTime, formatXLM, + truncateAddress, isValidAmount, - timeAgo, timeUntil, - toTimestampMs, - truncateAddress, + formatDate, + formatTime, + calculatePayout, + calculateOdds, + bpsToPercent, + explorerUrl, + formatEventTime, + timeAgo, } from "@/utils/helpers"; -describe("XLM formatting", () => { - it.each([ - [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.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); +// ── formatXLM ───────────────────────────────────────────────────────────────── + +describe("formatXLM", () => { + it("formats whole XLM correctly", () => { + expect(formatXLM(100_0000000n)).toBe("100 XLM"); + }); + + 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"); }); }); -describe("address and amount helpers", () => { - it("truncates long Stellar addresses", () => { +// ── truncateAddress ─────────────────────────────────────────────────────────── + +describe("truncateAddress", () => { + it("truncates a standard 56-char Stellar address", () => { + const addr = "GDHQ6TNWZ4V2JVCDWEUVW7YKFBXCOQZRRUCT27LAKES3PGOE6JSZMSMD"; + expect(truncateAddress(addr)).toBe("GDHQ...MSMD"); + }); + + it("truncates long addresses", () => { expect(truncateAddress("GABCDEFGHIJKLMNOPQRSTUVWXYZ234567")).toBe( "GABC...4567" ); }); - it("leaves short values unchanged", () => { + it("returns short strings as-is", () => { expect(truncateAddress("SHORT")).toBe("SHORT"); + }); + + it("returns empty string as-is", () => { expect(truncateAddress("")).toBe(""); }); - it.each([ - ["ABCDEFGHIJ", "ABCDEFGHIJ"], - ["ABCDEFGHIJK", "ABCD...HIJK"], - [ - "GDHQ6TNWZ4V2JVCDWEUVW7YKFBXCOQZRRUCT27LAKES3PGOE6JSZMSMD", - "GDHQ...MSMD", - ], - ])("handles address truncation boundary for %s", (address, expected) => { - expect(truncateAddress(address)).toBe(expected); + it("returns exactly 10-char string as-is", () => { + expect(truncateAddress("ABCDEFGHIJ")).toBe("ABCDEFGHIJ"); }); - it("validates positive amounts against the balance", () => { + it("truncates 11-char string", () => { + expect(truncateAddress("ABCDEFGHIJK")).toBe("ABCD...HIJK"); + }); +}); + +// ── isValidAmount ───────────────────────────────────────────────────────────── + +describe("isValidAmount", () => { + it("accepts valid amount within balance", () => { + expect(isValidAmount("50", 100)).toBe(true); + }); + + it("accepts minimum 1 XLM", () => { expect(isValidAmount("1", 100)).toBe(true); + }); + + it("accepts amount equal to balance", () => { expect(isValidAmount("100", 100)).toBe(true); + }); + + it("rejects amount below 1 XLM minimum", () => { expect(isValidAmount("0.5", 100)).toBe(false); - expect(isValidAmount("101", 100)).toBe(false); + }); + + it("rejects zero", () => { + expect(isValidAmount("0", 100)).toBe(false); + }); + + it("rejects negative amount", () => { + expect(isValidAmount("-5", 100)).toBe(false); + }); + + it("rejects amount exceeding balance", () => { + expect(isValidAmount("150", 100)).toBe(false); + }); + + it("rejects non-numeric strings", () => { expect(isValidAmount("abc", 100)).toBe(false); }); + + it("rejects empty string", () => { + expect(isValidAmount("", 100)).toBe(false); + }); }); -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" }; +// ── timeUntil ───────────────────────────────────────────────────────────────── +describe("timeUntil", () => { beforeEach(() => { vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-02-26T17:04:00.000Z")); + // Pin "now" to a known Unix time: 2026-02-26T00:00:00Z = 1771977600 + vi.setSystemTime(new Date("2026-02-26T00:00:00Z")); }); afterEach(() => { vi.useRealTimers(); }); - 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('returns "Ended" for past timestamps', () => { + expect(timeUntil(0)).toBe("Ended"); }); - 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)); + it('returns "Ended" for timestamp equal to now', () => { + const now = Math.floor(Date.now() / 1000); + expect(timeUntil(now)).toBe("Ended"); + }); - expect(formatDate(seconds, "en-US", options)).toBe(expected); - expect(formatDate(milliseconds, "en-US", options)).toBe(expected); + 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("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)); + 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"); + }); - expect(formatTime(seconds, "en-US", options)).toBe(expected); - expect(formatTime(milliseconds, "en-US", options)).toBe(expected); + 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.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, { + it("returns seconds when less than a minute", () => { + const now = Math.floor(Date.now() / 1000); + const future = now + 30; + expect(timeUntil(future)).toBe("30s"); + }); +}); + +// ── timestamp formatting ───────────────────────────────────────────────────── + +describe("timestamp formatting", () => { + const timestamp = Date.UTC(2026, 6, 18, 8, 30) / 1000; + + it("formats a complete timestamp in the requested locale and time zone", () => { + const expected = new Intl.DateTimeFormat("en-GB", { 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, { + timeZone: "Asia/Singapore", + }).format(new Date(timestamp * 1000)); + + expect( + formatDate(timestamp, "en-GB", { timeZone: "Asia/Singapore" }) + ).toBe(expected); + }); + + it("uses the requested locale instead of hard-coding en-US", () => { + const options = { timeZone: "UTC" }; + + expect(formatDate(timestamp, "de-DE", options)).not.toBe( + formatDate(timestamp, "en-US", options) + ); + }); + + it("uses the same local-time rules for compact activity timestamps", () => { + const expected = new Intl.DateTimeFormat("en-US", { hour: "2-digit", minute: "2-digit", timeZoneName: "short", - timeZone, - }).format(new Date(milliseconds)); + timeZone: "America/New_York", + }).format(new Date(timestamp * 1000)); - expect(formatDate(seconds, locale, dateOptions)).toBe(expectedDate); - expect(formatTime(seconds, locale, dateOptions)).toBe(expectedTime); + expect( + formatTime(timestamp, "en-US", { timeZone: "America/New_York" }) + ).toBe(expected); }); +}); - it("does not hard-code a single locale", () => { - expect(formatDate(seconds, "de-DE", options)).not.toBe( - formatDate(seconds, "en-US", options) - ); +// ── calculatePayout ─────────────────────────────────────────────────────────── + +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, - -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"); - }); - - 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"); + it("calculates proportional payout for multiple winners", () => { + // User bet 50, winning side total 200, pool 500 + expect(calculatePayout(50, 200, 500)).toBe(125); }); - 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 equal split payout", () => { + // 2 equal winners: user bet 50, winning side 100, pool 200 + expect(calculatePayout(50, 100, 200)).toBe(100); }); -}); -describe("market calculations", () => { - it("calculates proportional payouts", () => { - expect(calculatePayout(50, 200, 500)).toBe(125); + it("returns 0 if winning side total is 0", () => { expect(calculatePayout(100, 0, 500)).toBe(0); }); - it("calculates odds that always total 100", () => { + 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", () => { expect(calculateOdds(0, 0)).toEqual({ yesPercent: 50, noPercent: 50 }); - expect(calculateOdds(1, 2)).toEqual({ yesPercent: 33, noPercent: 67 }); }); - it("converts basis points", () => { + 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%", () => { expect(bpsToPercent(200)).toBe("2%"); + }); + + it("converts 150 bps to 1.5%", () => { expect(bpsToPercent(150)).toBe("1.5%"); }); - it("builds Stellar Expert links", () => { + 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("converts 0 bps to 0%", () => { + expect(bpsToPercent(0)).toBe("0%"); + }); +}); + +// ── explorerUrl ─────────────────────────────────────────────────────────────── + +describe("explorerUrl", () => { + it("builds transaction URL", () => { 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" + }); + + it("builds account URL", () => { + expect(explorerUrl("account", "GABC")).toBe( + "https://stellar.expert/explorer/public/account/GABC" + ); + }); + + it("builds contract URL", () => { + expect(explorerUrl("contract", "CDEF456")).toBe( + "https://stellar.expert/explorer/public/contract/CDEF456" ); }); }); + +// ── 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("—"); + }); +}); diff --git a/frontend/src/app/leaderboard/page.tsx b/frontend/src/app/leaderboard/page.tsx index 191619b..9c8551d 100644 --- a/frontend/src/app/leaderboard/page.tsx +++ b/frontend/src/app/leaderboard/page.tsx @@ -1,57 +1,44 @@ "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, formatDate } from "@/utils/helpers"; +import { formatDate } from "@/utils/helpers"; export default function LeaderboardPage() { const [tab, setTab] = useState("top_predictors"); const { data: players, loading, error, lastUpdated } = useLeaderboard(tab); const { publicKey } = useWallet(); - const [, forceUpdate] = useState(0); - - // 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 -

-
- - - - - + {/* Header */} +
+
+

+ Leaderboard +

+ + Live
-
-

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

- {!loading && lastUpdated && ( -

- Updated {timeAgo(lastUpdated)} -

- )} + {/* Tabs */} +
+ setTab(t as LeaderboardTab)} + />
- - {lastUpdated && (
Last updated: {formatDate(lastUpdated)} @@ -61,22 +48,15 @@ export default function LeaderboardPage() { {/* Content */} {loading ? ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + + + +
+ ))}
) : error ? (
@@ -85,92 +65,16 @@ 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 || `${player.address.slice(0, 4)}...${player.address.slice(-4)}`} - - {isCurrentUser && ( - - You - - )} -
-
- {player.points.toLocaleString()} - - = 50 - ? "text-accent-mint" - : "text-accent-red" - } - > - {player.winRate}% - - - {player.totalBets} -
-
+
+
)} diff --git a/frontend/src/app/markets/[id]/page.tsx b/frontend/src/app/markets/[id]/page.tsx index 8557ad4..604a6f5 100644 --- a/frontend/src/app/markets/[id]/page.tsx +++ b/frontend/src/app/markets/[id]/page.tsx @@ -10,7 +10,6 @@ import { getXlmBalance } from "@/services/soroban"; import { displayXLM, formatXLM, - formatTime, formatEventTime, calculatePayout, truncateAddress, @@ -326,7 +325,7 @@ export default function MarketDetailPage({
- {formatTime(evt.timestamp)} + {formatEventTime(evt.timestamp)}
))} diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index 3f28ea7..92131a6 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -1,11 +1,20 @@ -// ── Pure Utility Functions ─────────────────────────────────────────────────── - const STROOPS_PER_XLM = 10_000_000n; -const MILLISECOND_TIMESTAMP_THRESHOLD = 4_102_444_800; -const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000; -const INVALID_TIMESTAMP = "—"; +const DASH = "—"; +const SECONDS_MS_THRESHOLD = 4_102_444_800; + +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", +}; -/** Convert stroops (bigint) to a human-readable XLM string. */ export function formatXLM(stroops: bigint): string { const isNegative = stroops < 0n; const abs = isNegative ? -stroops : stroops; @@ -14,251 +23,119 @@ export function formatXLM(stroops: bigint): string { const fracStr = fractional.toString().padStart(7, "0").replace(/0+$/, ""); const sign = isNegative ? "-" : ""; - return fracStr.length === 0 - ? `${sign}${whole} XLM` - : `${sign}${whole}.${fracStr} XLM`; + if (fracStr.length === 0) { + return `${sign}${whole} XLM`; + } + return `${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 (Number.isNaN(parsed) || parsed < 1) return false; + if (isNaN(parsed) || parsed < 1) return false; return parsed <= balance; } -/** 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 / 86_400); - const hours = Math.floor((diff % 86_400) / 3_600); - const minutes = Math.floor((diff % 3_600) / 60); + const days = Math.floor(diff / 86400); + const hours = Math.floor((diff % 86400) / 3600); + const minutes = Math.floor((diff % 3600) / 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`; } -/** - * Normalize a Unix timestamp that may be seconds or milliseconds to ms. - * Values below ~1e12 are almost certainly seconds; above are ms. - */ export function toTimestampMs(timestamp: number): number { - if (!Number.isFinite(timestamp)) return Date.now(); - return timestamp < 1e12 ? timestamp * 1000 : timestamp; + return timestamp > SECONDS_MS_THRESHOLD ? timestamp : timestamp * 1000; } -const DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", -}; +function isValidTimestamp(timestamp: number): boolean { + return Number.isFinite(timestamp) && timestamp > 0; +} -const TIME_OPTIONS: Intl.DateTimeFormatOptions = { - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", -}; +function withFormatOptions( + base: Intl.DateTimeFormatOptions, + optionsOrTimeZone?: Intl.DateTimeFormatOptions | string, + includeTimeZoneName = true +): Intl.DateTimeFormatOptions { + if (typeof optionsOrTimeZone === "string") { + return { ...base, timeZone: optionsOrTimeZone }; + } -/** - * Format a Unix timestamp (seconds) to a locale-aware date/time string. - * Uses the viewer's browser locale and local timezone automatically. - * - * Example (en-GB): "12 Jul 2026, 14:30 GMT+1" - * Example (en-US): "Jul 12, 2026, 10:30 AM EDT" - */ -/** Normalize a positive Unix timestamp supplied in seconds or milliseconds. */ -export function toTimestampMs(timestamp: number): number { - 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; + return { + ...base, + ...(includeTimeZoneName ? { timeZoneName: "short" as const } : {}), + ...optionsOrTimeZone, + }; } -/** Format a timestamp in the viewer's timezone, including its timezone label. */ export function formatDate( timestamp: number, locale?: Intl.LocalesArgument, - options: Intl.DateTimeFormatOptions = {} + optionsOrTimeZone?: Intl.DateTimeFormatOptions | string ): string { - if (!Number.isFinite(timestamp) || timestamp <= 0) return "—"; - // 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 Intl.DateTimeFormat(locale, { - ...DATE_TIME_OPTIONS, - ...options, - }).format(new Date(ms)); + if (!isValidTimestamp(timestamp)) return DASH; + return new Date(toTimestampMs(timestamp)).toLocaleString( + locale, + withFormatOptions( + DATE_TIME_OPTIONS, + optionsOrTimeZone, + typeof optionsOrTimeZone !== "string" + ) + ); } -/** - * Format a Unix timestamp to a locale-aware time-only string. - */ -export function formatTime( +export function formatDateTime( timestamp: number, locale?: Intl.LocalesArgument, - options?: Intl.DateTimeFormatOptions + optionsOrTimeZone?: Intl.DateTimeFormatOptions | string ): string { - if (!Number.isFinite(timestamp) || timestamp <= 0) return "—"; - const ms = timestamp > 4_102_444_800 ? timestamp : timestamp * 1000; - return new Intl.DateTimeFormat(locale, { - ...TIME_OPTIONS, - ...options, - }).format(new Date(ms)); + return formatDate(timestamp, locale, optionsOrTimeZone); } -/** - * Format an event timestamp (milliseconds) to a locale-aware date+time string. - * Use this for MarketEvent.timestamp — it is already in milliseconds. - */ -export function formatEventTime(timestampMs: number): string { - if (!Number.isFinite(timestampMs) || timestampMs <= 0) return "—"; - return new Date(timestampMs).toLocaleString(undefined, DATE_TIME_OPTIONS); -} - -/** - * Return a human-readable relative time string from a Unix timestamp (seconds). - * Uses the viewer's locale via Intl.RelativeTimeFormat. - * - * Examples: "2 hours ago", "3 days ago", "just now" - */ -export function timeAgo(timestamp: number): string { - if (!Number.isFinite(timestamp) || timestamp <= 0) return "—"; - 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 idx = thresholds.findIndex(([l]) => l === limit); - const prev = idx > 0 ? thresholds[idx - 1] : [1, "second"] as const; - const divisor = prev[0]; - 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 - const timestampMs = toTimestampMs(timestamp); - if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP; - - return new Intl.DateTimeFormat(locale, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - ...options, - }).format(new Date(timestampMs)); -} - -/** Format only the local time portion of a timestamp, with its timezone label. */ export function formatTime( timestamp: number, locale?: Intl.LocalesArgument, - options: Intl.DateTimeFormatOptions = {} + optionsOrTimeZone?: Intl.DateTimeFormatOptions | string ): string { - const timestampMs = toTimestampMs(timestamp); - if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP; - - return new Intl.DateTimeFormat(locale, { - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - ...options, - }).format(new Date(timestampMs)); -} - -/** 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], - ]; - const [unit, unitSeconds] = - units.find(([, seconds]) => absoluteSeconds >= seconds) ?? units[6]; - - const value = - Math.sign(diffSeconds) * Math.round(absoluteSeconds / unitSeconds); - return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format( - value, - unit + if (!isValidTimestamp(timestamp)) return DASH; + return new Date(toTimestampMs(timestamp)).toLocaleTimeString( + locale, + withFormatOptions( + TIME_OPTIONS, + optionsOrTimeZone, + typeof optionsOrTimeZone !== "string" + ) ); } -/** 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 INVALID_TIMESTAMP; - return new Date(timestampMs).toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - }); +export function formatEventTime( + timestampMs: number, + locale?: Intl.LocalesArgument, + optionsOrTimeZone?: Intl.DateTimeFormatOptions | string +): string { + if (!isValidTimestamp(timestampMs)) return DASH; + return formatDate(timestampMs, locale, optionsOrTimeZone); } -/** Calculate a winner's payout from a prediction market. - * - * payout = (userNetBet / winningSideTotal) × totalPool - * - * All values in XLM (not stroops). - */ export function calculatePayout( userNetBet: number, winningSideTotal: number, @@ -268,50 +145,53 @@ 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. - * Returns { yesPercent, noPercent } – each 0-100. - */ export function calculateOdds( totalYes: number, totalNo: number ): { yesPercent: number; noPercent: number } { const total = totalYes + totalNo; - if (total <= 0) return { yesPercent: 50, noPercent: 50 }; + if (total === 0) return { yesPercent: 50, noPercent: 50 }; const yesPercent = Math.round((totalYes / total) * 100); return { yesPercent, noPercent: 100 - yesPercent }; } -/** - * Build a Stellar Expert explorer URL for transactions, accounts, or contracts. - */ -/** Convert basis points to a percentage string. */ export function bpsToPercent(bps: number): string { - return `${bps / 100}%`; + const percent = bps / 100; + return `${percent}`.replace(/\.0$/, "") + "%"; } -/** Build a Stellar Expert explorer URL. */ export function explorerUrl( type: "tx" | "account" | "contract", id: string, network: "public" | "testnet" = "public" ): string { - const base = - network === "testnet" - ? "https://stellar.expert/explorer/testnet" - : "https://stellar.expert/explorer/public"; - return `${base}/${type}/${id}`; - 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; + return `https://stellar.expert/explorer/${network}/${type}/${id}`; +} + +export function timeAgo(timestamp: number): string { + if (!isValidTimestamp(timestamp)) return DASH; + + const diffSeconds = Math.max( + 0, + Math.floor((Date.now() - toTimestampMs(timestamp)) / 1000) + ); + if (diffSeconds < 5) return "just now"; + + const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + const intervals: [Intl.RelativeTimeFormatUnit, number][] = [ + ["year", 31_536_000], + ["month", 2_592_000], + ["day", 86_400], + ["hour", 3_600], + ["minute", 60], + ["second", 1], + ]; + + for (const [unit, secondsInUnit] of intervals) { + if (diffSeconds >= secondsInUnit || unit === "second") { + return rtf.format(-Math.floor(diffSeconds / secondsInUnit), unit); + } } + + return rtf.format(0, "second"); }