From e94f6eea484f520d04009bff777b1e05ed1dbabd Mon Sep 17 00:00:00 2001 From: Praveen chand Date: Thu, 6 Aug 2026 20:44:43 +0530 Subject: [PATCH] fix(leaderboard): normalize timezone display in timestamp helpers - Remove duplicate toTimestampMs/formatDate/formatTime/timeAgo definitions left by a bad merge; the duplicate toTimestampMs returned Date.now() for invalid input instead of the invalid sentinel. - Route formatDate/formatTime through the shared toTimestampMs so seconds and milliseconds are normalized identically everywhere. - Make formatEventTime use Intl.DateTimeFormat consistently with the rest of the file. - Add coverage for formatEventTime including invalid-input handling. --- frontend/src/__tests__/helpers.test.ts | 38 ++++++ frontend/src/utils/helpers.ts | 168 ++++++------------------- 2 files changed, 77 insertions(+), 129 deletions(-) diff --git a/frontend/src/__tests__/helpers.test.ts b/frontend/src/__tests__/helpers.test.ts index efc1bb4..77dd68b 100644 --- a/frontend/src/__tests__/helpers.test.ts +++ b/frontend/src/__tests__/helpers.test.ts @@ -6,6 +6,7 @@ import { displayXLM, explorerUrl, formatDate, + formatEventTime, formatTime, formatXLM, isValidAmount, @@ -222,6 +223,43 @@ describe("timestamp helpers", () => { const now = Math.floor(Date.now() / 1_000); expect(timeUntil(now + offset)).toBe(expected); }); + + it("formats an already-millisecond event timestamp without second conversion", () => { + const expected = new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }).format(new Date(milliseconds)); + + expect(formatEventTime(milliseconds)).toBe(expected); + }); + + it("formats the same event instant in a different timezone", () => { + const expected = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + timeZone: "America/New_York", + }).format(new Date(milliseconds)); + + expect(formatEventTime(milliseconds)).not.toBe(expected); + }); + + it.each([ + 0, + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ])("rejects invalid event timestamps %s", (value) => { + expect(formatEventTime(value)).toBe("—"); + }); }); describe("market calculations", () => { diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index 3f28ea7..bb20f05 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -56,15 +56,6 @@ export function timeUntil(timestamp: number): string { 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; -} - const DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", @@ -81,13 +72,11 @@ const TIME_OPTIONS: Intl.DateTimeFormatOptions = { }; /** - * 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. + * Values below MILLISECOND_TIMESTAMP_THRESHOLD (~year 2100 in seconds) + * are treated as seconds; larger values are treated as milliseconds. + * Returns NaN for invalid/out-of-range timestamps. */ -/** 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 = @@ -97,100 +86,31 @@ export function toTimestampMs(timestamp: number): number { return timestampMs <= MAX_DATE_TIMESTAMP_MS ? timestampMs : Number.NaN; } -/** Format a timestamp in the viewer's timezone, including its timezone label. */ -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, { - ...DATE_TIME_OPTIONS, - ...options, - }).format(new Date(ms)); -} - /** - * Format a Unix timestamp to a locale-aware time-only string. + * Format a Unix timestamp (seconds or milliseconds) to a locale-aware + * date/time string using the viewer's browser locale and timezone. + * + * Example (en-GB): "12 Jul 2026, 14:30 BST" + * Example (en-US): "Jul 12, 2026, 10:30 AM EDT" */ -export function formatTime( +export function formatDate( 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, { - ...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" - */ -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", + ...DATE_TIME_OPTIONS, ...options, }).format(new Date(timestampMs)); } -/** Format only the local time portion of a timestamp, with its timezone label. */ +/** + * Format a Unix timestamp (seconds or milliseconds) to a locale-aware time-only + * string with its timezone label. + */ export function formatTime( timestamp: number, locale?: Intl.LocalesArgument, @@ -207,7 +127,12 @@ export function formatTime( }).format(new Date(timestampMs)); } -/** Format a timestamp relative to now while accepting seconds or milliseconds. */ +/** + * Format a timestamp relative to now, accepting seconds or milliseconds. + * Uses Intl.RelativeTimeFormat for locale-aware output. + * + * Examples: "2 hours ago", "3 days ago", "just now" + */ export function timeAgo( timestamp: number, locale?: Intl.LocalesArgument @@ -239,23 +164,24 @@ export function timeAgo( ); } -/** 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. */ +/** + * Format an event timestamp (already in milliseconds) to a locale-aware + * date+time string. Unlike formatDate, this does NOT apply second→ms conversion. + * + * Use this for MarketEvent.timestamp — it is already in milliseconds. + */ 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", - }); + if (!Number.isFinite(timestampMs) || timestampMs <= 0) + return INVALID_TIMESTAMP; + return new Intl.DateTimeFormat(undefined, DATE_TIME_OPTIONS).format( + new Date(timestampMs) + ); } -/** Calculate a winner's payout from a prediction market. +/** + * Calculate a winner's payout from a prediction market. * - * payout = (userNetBet / winningSideTotal) × totalPool + * payout = (userNetBet / winningSideTotal) * totalPool * * All values in XLM (not stroops). */ @@ -270,9 +196,7 @@ export function calculatePayout( /** * 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. + * Returns { yesPercent, noPercent } — each 0-0. */ export function calculateOdds( totalYes: number, @@ -284,15 +208,12 @@ export function calculateOdds( 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}%`; } -/** Build a Stellar Expert explorer URL. */ +/** Build a Stellar Expert explorer URL for transactions, accounts, or contracts. */ export function explorerUrl( type: "tx" | "account" | "contract", id: string, @@ -303,15 +224,4 @@ export function explorerUrl( ? "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; - } -} +} \ No newline at end of file