diff --git a/frontend/src/__tests__/leaderboard-page.test.tsx b/frontend/src/__tests__/leaderboard-page.test.tsx index 94244dc..49dff57 100644 --- a/frontend/src/__tests__/leaderboard-page.test.tsx +++ b/frontend/src/__tests__/leaderboard-page.test.tsx @@ -4,8 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ lastUpdated: 1_800_000_000 as number | null, - formatDate: vi.fn(() => "localized timestamp"), - timeAgo: vi.fn(() => "5 minutes ago"), + localizedTimestamp: vi.fn(), })); vi.mock("@/hooks/useLeaderboard", () => ({ @@ -22,9 +21,11 @@ vi.mock("@/hooks/useWallet", () => ({ useWallet: () => ({ publicKey: null }), })); -vi.mock("@/utils/helpers", () => ({ - formatDate: mocks.formatDate, - timeAgo: mocks.timeAgo, +vi.mock("@/components/ui/LocalizedTimestamp", () => ({ + default: (props: { timestamp: number; mode?: string }) => { + mocks.localizedTimestamp(props); + return props.mode === "relative" ? "5 minutes ago" : "localized timestamp"; + }, })); import LeaderboardPage from "@/app/leaderboard/page"; @@ -35,17 +36,31 @@ describe("LeaderboardPage", () => { mocks.lastUpdated = 1_800_000_000; }); - it("formats the last successful leaderboard refresh", () => { + it("renders relative and absolute localized refresh timestamps", () => { render(); - expect(screen.getByText("Last updated: localized timestamp")).toBeInTheDocument(); - expect(mocks.formatDate).toHaveBeenCalledWith(1_800_000_000); + expect(screen.getByText(/^Updated 5 minutes ago$/)).toBeInTheDocument(); + expect( + screen.getByText(/^Last updated: localized timestamp$/) + ).toBeInTheDocument(); + + const props = mocks.localizedTimestamp.mock.calls.map(([value]) => value); + expect(props).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + timestamp: 1_800_000_000, + mode: "relative", + }), + expect.objectContaining({ timestamp: 1_800_000_000 }), + ]) + ); }); - it("hides the label before any successful refresh", () => { + it("hides timestamp labels before any successful refresh", () => { mocks.lastUpdated = null; render(); expect(screen.queryByText(/^Last updated:/)).not.toBeInTheDocument(); + expect(mocks.localizedTimestamp).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/__tests__/localized-timestamp.test.tsx b/frontend/src/__tests__/localized-timestamp.test.tsx new file mode 100644 index 0000000..daa9431 --- /dev/null +++ b/frontend/src/__tests__/localized-timestamp.test.tsx @@ -0,0 +1,137 @@ +import React from "react"; +import { act } from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { hydrateRoot, type Root } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import LocalizedTimestamp from "@/components/ui/LocalizedTimestamp"; +import { formatDate, formatTime } from "@/utils/helpers"; + +const timestamp = Date.UTC(2026, 6, 12, 14, 30) / 1_000; + +describe("LocalizedTimestamp", () => { + it("renders deterministic UTC markup during SSR", () => { + const expected = formatDate(timestamp, "en-US", { timeZone: "UTC" }); + const html = renderToString(); + + expect(html).toContain(expected); + expect(html).toContain('data-time-zone="UTC"'); + expect(html.toLowerCase()).toContain('datetime="2026-07-12t14:30:00.000z"'); + }); + + it("hydrates UTC markup before switching to the browser locale and timezone", async () => { + const serverValue = formatDate(timestamp, "en-US", { timeZone: "UTC" }); + const browserValue = formatDate(timestamp, "es-ES", { + timeZone: "Europe/Madrid", + }); + const originalLanguages = Object.getOwnPropertyDescriptor( + navigator, + "languages" + ); + const resolvedOptions = new Intl.DateTimeFormat().resolvedOptions(); + const resolvedOptionsSpy = vi + .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") + .mockReturnValue({ + ...resolvedOptions, + locale: "es-ES", + timeZone: "Europe/Madrid", + }); + const container = document.createElement("div"); + let root: Root | undefined; + + Object.defineProperty(navigator, "languages", { + configurable: true, + value: ["es-ES"], + }); + container.innerHTML = renderToString( + + ); + document.body.appendChild(container); + + try { + expect(container).toHaveTextContent(serverValue); + + await act(async () => { + root = hydrateRoot( + container, + + ); + }); + + await waitFor(() => expect(container).toHaveTextContent(browserValue)); + expect(container.querySelector("time")).toHaveAttribute( + "data-time-zone", + "Europe/Madrid" + ); + } finally { + if (root) { + await act(async () => root?.unmount()); + } + container.remove(); + resolvedOptionsSpy.mockRestore(); + if (originalLanguages) { + Object.defineProperty(navigator, "languages", originalLanguages); + } else { + Reflect.deleteProperty(navigator, "languages"); + } + } + }); + + it.each([ + ["America/New_York", "en-US"], + ["Europe/Madrid", "es-ES"], + ["Asia/Tokyo", "ja-JP"], + ])("formats the same instant in %s", (timeZone, locale) => { + const expected = formatDate(timestamp, locale, { timeZone }); + + render( + + ); + + expect(screen.getByText(expected)).toHaveAttribute( + "data-time-zone", + timeZone + ); + }); + + it("supports a time-only view", () => { + const expected = formatTime(timestamp, "en-GB", { + timeZone: "Europe/London", + }); + + render( + + ); + + expect(screen.getByText(expected)).toBeInTheDocument(); + }); + + it("renders relative timestamps deterministically when nowMs is supplied", () => { + const nowMs = Date.UTC(2026, 6, 12, 16, 30); + + render( + + ); + + expect(screen.getByText("2 hours ago")).toBeInTheDocument(); + }); + + it("renders invalid input as an em dash", () => { + render(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/leaderboard/page.tsx b/frontend/src/app/leaderboard/page.tsx index 191619b..caa98e9 100644 --- a/frontend/src/app/leaderboard/page.tsx +++ b/frontend/src/app/leaderboard/page.tsx @@ -7,7 +7,7 @@ import LeaderboardTabs from "@/components/leaderboard/LeaderboardTabs"; 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 LocalizedTimestamp from "@/components/ui/LocalizedTimestamp"; export default function LeaderboardPage() { const [tab, setTab] = useState("top_predictors"); @@ -45,7 +45,7 @@ export default function LeaderboardPage() {

{!loading && lastUpdated && (

- Updated {timeAgo(lastUpdated)} + Updated

)} @@ -54,7 +54,7 @@ export default function LeaderboardPage() { {lastUpdated && (
- Last updated: {formatDate(lastUpdated)} + Last updated:
)} diff --git a/frontend/src/components/leaderboard/LeaderboardTable.tsx b/frontend/src/components/leaderboard/LeaderboardTable.tsx index 02b3506..d3906ed 100644 --- a/frontend/src/components/leaderboard/LeaderboardTable.tsx +++ b/frontend/src/components/leaderboard/LeaderboardTable.tsx @@ -2,7 +2,7 @@ import React from "react"; import type { PlayerStats } from "@/types"; import PlayerRow from "./PlayerRow"; import { FiUser } from "react-icons/fi"; -import { formatDate } from "@/utils/helpers"; +import LocalizedTimestamp from "@/components/ui/LocalizedTimestamp"; interface LeaderboardTableProps { players: PlayerStats[]; @@ -49,7 +49,7 @@ export default function LeaderboardTable({ {lastUpdatedAt && (
- Last updated: {formatDate(lastUpdatedAt)} + Last updated:
)} diff --git a/frontend/src/components/ui/LocalizedTimestamp.tsx b/frontend/src/components/ui/LocalizedTimestamp.tsx new file mode 100644 index 0000000..84a73bf --- /dev/null +++ b/frontend/src/components/ui/LocalizedTimestamp.tsx @@ -0,0 +1,115 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { + formatDate, + formatTime, + timeAgo, + toTimestampMs, +} from "@/utils/helpers"; + +const SERVER_LOCALE = "en-US"; +const SERVER_TIME_ZONE = "UTC"; + +type TimestampMode = "date" | "time" | "relative"; + +interface FormattingContext { + locale: string; + timeZone: string; +} + +export interface LocalizedTimestampProps { + timestamp: number; + mode?: TimestampMode; + locale?: string; + timeZone?: string; + options?: Intl.DateTimeFormatOptions; + className?: string; + nowMs?: number; +} + +/** + * Resolve the browser's preferred locale and IANA timezone. Kept behind a + * function so importing this module during SSR never touches browser globals. + */ +export function getBrowserFormattingContext(): FormattingContext { + if (typeof navigator === "undefined") { + return { locale: SERVER_LOCALE, timeZone: SERVER_TIME_ZONE }; + } + + const locale = navigator.languages?.[0] || navigator.language || SERVER_LOCALE; + let timeZone = SERVER_TIME_ZONE; + + try { + timeZone = + new Intl.DateTimeFormat().resolvedOptions().timeZone || SERVER_TIME_ZONE; + } catch { + // Keep the deterministic UTC fallback when Intl data is unavailable. + } + + return { locale, timeZone }; +} + +/** + * Hydration-safe timestamp renderer. + * + * Server output and the first client render both use en-US/UTC. After mount, + * the component switches to the viewer's actual locale and timezone. This + * avoids rendering server-local time into the HTML and prevents hydration + * mismatches for users outside the server timezone. + */ +export default function LocalizedTimestamp({ + timestamp, + mode = "date", + locale, + timeZone, + options = {}, + className, + nowMs, +}: LocalizedTimestampProps) { + const [context, setContext] = useState(() => ({ + locale: locale || SERVER_LOCALE, + timeZone: timeZone || SERVER_TIME_ZONE, + })); + + useEffect(() => { + const browser = getBrowserFormattingContext(); + const next = { + locale: locale || browser.locale, + timeZone: timeZone || browser.timeZone, + }; + + setContext((current) => + current.locale === next.locale && current.timeZone === next.timeZone + ? current + : next + ); + }, [locale, timeZone]); + + const timestampMs = toTimestampMs(timestamp); + if (!Number.isFinite(timestampMs)) { + return ; + } + + let value: string; + if (mode === "relative") { + value = timeAgo(timestamp, context.locale, nowMs); + } else { + const localizedOptions = { ...options, timeZone: context.timeZone }; + value = + mode === "time" + ? formatTime(timestamp, context.locale, localizedOptions) + : formatDate(timestamp, context.locale, localizedOptions); + } + + return ( + + ); +} diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index 3f28ea7..8ef1af1 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -1,7 +1,7 @@ // ── Pure Utility Functions ─────────────────────────────────────────────────── const STROOPS_PER_XLM = 10_000_000n; -const MILLISECOND_TIMESTAMP_THRESHOLD = 4_102_444_800; +const MILLISECOND_TIMESTAMP_THRESHOLD = 100_000_000_000; const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000; const INVALID_TIMESTAMP = "—"; @@ -34,16 +34,17 @@ export function truncateAddress(addr: string): string { /** Validate a bet amount against the minimum and the user's balance. */ export function isValidAmount(amount: string, balance: number): boolean { - const parsed = parseFloat(amount); + const parsed = Number.parseFloat(amount); if (Number.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 (!Number.isFinite(timestamp)) return "Ended"; + const now = Math.floor(Date.now() / 1_000); + const diff = Math.floor(timestamp - now); if (diff <= 0) return "Ended"; const days = Math.floor(diff / 86_400); @@ -57,12 +58,19 @@ export function timeUntil(timestamp: number): string { } /** - * Normalize a Unix timestamp that may be seconds or milliseconds to ms. - * Values below ~1e12 are almost certainly seconds; above are ms. + * Normalize a positive Unix timestamp supplied in seconds or milliseconds. + * Values below 1e11 are treated as seconds; newer millisecond timestamps are + * already above that boundary. Invalid or out-of-range values return NaN. */ export function toTimestampMs(timestamp: number): number { - if (!Number.isFinite(timestamp)) return Date.now(); - return timestamp < 1e12 ? timestamp * 1000 : timestamp; + 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; } const DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = { @@ -80,146 +88,83 @@ const TIME_OPTIONS: Intl.DateTimeFormatOptions = { timeZoneName: "short", }; -/** - * 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; +function formatTimestampMs( + timestampMs: number, + locale: Intl.LocalesArgument | undefined, + options: Intl.DateTimeFormatOptions +): string { + if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP; + + try { + return new Intl.DateTimeFormat(locale, options).format(new Date(timestampMs)); + } catch { + return INVALID_TIMESTAMP; + } } -/** Format a timestamp in the viewer's timezone, including its timezone label. */ +/** Format seconds or milliseconds in the requested locale and timezone. */ export function formatDate( timestamp: number, locale?: Intl.LocalesArgument, options: Intl.DateTimeFormatOptions = {} ): 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, { + return formatTimestampMs(toTimestampMs(timestamp), locale, { ...DATE_TIME_OPTIONS, ...options, - }).format(new Date(ms)); + }); } -/** - * Format a Unix timestamp to a locale-aware time-only string. - */ +/** Format only the local time portion, including its timezone label. */ export function formatTime( timestamp: number, locale?: Intl.LocalesArgument, - options?: Intl.DateTimeFormatOptions + options: Intl.DateTimeFormatOptions = {} ): string { - if (!Number.isFinite(timestamp) || timestamp <= 0) return "—"; - const ms = timestamp > 4_102_444_800 ? timestamp : timestamp * 1000; - return new Intl.DateTimeFormat(locale, { + return formatTimestampMs(toTimestampMs(timestamp), locale, { ...TIME_OPTIONS, ...options, - }).format(new Date(ms)); -} - -/** - * 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" + * Format an event timestamp that is explicitly supplied in milliseconds. + * This separate entry point prevents accidental double conversion. */ -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, +export function formatEventTime( + timestampMs: number, locale?: Intl.LocalesArgument, options: Intl.DateTimeFormatOptions = {} ): string { - const timestampMs = toTimestampMs(timestamp); - if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP; + if ( + !Number.isFinite(timestampMs) || + timestampMs <= 0 || + timestampMs > MAX_DATE_TIMESTAMP_MS + ) { + return INVALID_TIMESTAMP; + } - return new Intl.DateTimeFormat(locale, { - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", + return formatTimestampMs(timestampMs, locale, { + ...DATE_TIME_OPTIONS, ...options, - }).format(new Date(timestampMs)); + }); } -/** Format a timestamp relative to now while accepting seconds or milliseconds. */ +/** Format a past or future timestamp relative to now. */ export function timeAgo( timestamp: number, - locale?: Intl.LocalesArgument + locale?: Intl.LocalesArgument, + nowMs: number = Date.now() ): string { const timestampMs = toTimestampMs(timestamp); - if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP; + if (!Number.isFinite(timestampMs) || !Number.isFinite(nowMs)) { + return INVALID_TIMESTAMP; + } - const diffSeconds = (timestampMs - Date.now()) / 1_000; + const diffSeconds = (timestampMs - nowMs) / 1_000; const absoluteSeconds = Math.abs(diffSeconds); if (absoluteSeconds < 5) return "just now"; - const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [ + const units: ReadonlyArray = [ ["year", 31_536_000], ["month", 2_592_000], ["week", 604_800], @@ -228,37 +173,23 @@ export function timeAgo( ["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 - ); -} - -/** 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", - }); + const magnitude = Math.max(1, Math.round(absoluteSeconds / unitSeconds)); + const value = diffSeconds < 0 ? -magnitude : magnitude; + + try { + return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format( + value, + unit + ); + } catch { + return INVALID_TIMESTAMP; + } } -/** Calculate a winner's payout from a prediction market. - * - * payout = (userNetBet / winningSideTotal) × totalPool - * - * All values in XLM (not stroops). - */ +/** Calculate a winner's proportional prediction-market payout. */ export function calculatePayout( userNetBet: number, winningSideTotal: number, @@ -268,25 +199,18 @@ 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. - */ +/** Calculate YES/NO percentages that always total 100. */ export function calculateOdds( totalYes: number, totalNo: number ): { yesPercent: number; noPercent: number } { const total = totalYes + totalNo; 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}%`; @@ -298,20 +222,6 @@ export function explorerUrl( 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 `${base}/${type}/${id}`; }