Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/src/app/markets/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { pollMarketEvents } from "@/services/events";
import { getXlmBalance } from "@/services/soroban";
import {
displayXLM,
formatTime,
formatXLM,
formatTime,
formatEventTime,
calculatePayout,
truncateAddress,
} from "@/utils/helpers";
Expand Down
115 changes: 115 additions & 0 deletions frontend/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,37 @@ 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",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
};

const TIME_OPTIONS: Intl.DateTimeFormatOptions = {
hour: "2-digit",
minute: "2-digit",
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;
Expand All @@ -72,6 +103,79 @@ export function formatDate(
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.
*/
export function formatTime(
timestamp: number,
locale?: Intl.LocalesArgument,
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;

Expand Down Expand Up @@ -164,6 +268,9 @@ 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.
*/
Expand All @@ -177,6 +284,9 @@ 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}%`;
Expand All @@ -188,6 +298,11 @@ 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":
Expand Down