Skip to content
Closed
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
288 changes: 46 additions & 242 deletions frontend/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,305 +13,109 @@ export function formatXLM(stroops: bigint): string {
const fractional = abs % STROOPS_PER_XLM;
const fracStr = fractional.toString().padStart(7, "0").replace(/0+$/, "");
const sign = isNegative ? "-" : "";

return fracStr.length === 0
? `${sign}${whole} XLM`
: `${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`;
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)}`;
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;
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);

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`;
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;
if (!Number.isFinite(timestamp) || timestamp <= 0) return Number.NaN;
const ts = timestamp < MILLISECOND_TIMESTAMP_THRESHOLD ? timestamp * 1_000 : timestamp;
return ts <= MAX_DATE_TIMESTAMP_MS ? ts : Number.NaN;
}

const DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
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",
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;
const timestampMs =
timestamp < MILLISECOND_TIMESTAMP_THRESHOLD
? timestamp * 1_000
: timestamp;
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.
*/
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 a timestamp in the viewer's locale timezone — fixes issue #80 */
export function formatDate(timestamp: number, locale?: Intl.LocalesArgument, options: Intl.DateTimeFormatOptions = {}): string {
const ts = toTimestampMs(timestamp);
if (!Number.isFinite(ts)) return INVALID_TIMESTAMP;
return new Intl.DateTimeFormat(locale ?? navigator.language, { ...DATE_TIME_OPTIONS, ...options }).format(new Date(ts));
}

/**
* 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);
/** Format only the local time portion — fixes issue #80 */
export function formatTime(timestamp: number, locale?: Intl.LocalesArgument, options: Intl.DateTimeFormatOptions = {}): string {
const ts = toTimestampMs(timestamp);
if (!Number.isFinite(ts)) return INVALID_TIMESTAMP;
return new Intl.DateTimeFormat(locale ?? navigator.language, { ...TIME_OPTIONS, ...options }).format(new Date(ts));
}

/**
* 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 = {}
): 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";

/** Format relative time — fixes issue #80 */
export function timeAgo(timestamp: number, locale?: Intl.LocalesArgument): string {
const ts = toTimestampMs(timestamp);
if (!Number.isFinite(ts)) return INVALID_TIMESTAMP;
const diffSeconds = (ts - Date.now()) / 1_000;
const absSeconds = Math.abs(diffSeconds);
if (absSeconds < 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],
["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
);
const [unit, unitSec] = units.find(([, s]) => absSeconds >= s) ?? units[6];
const value = Math.sign(diffSeconds) * Math.round(absSeconds / unitSec);
return new Intl.RelativeTimeFormat(locale ?? navigator.language, { 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",
});
return new Date(timestampMs).toLocaleString(navigator.language, DATE_TIME_OPTIONS);
}

/** 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,
totalPool: number
): number {
export function calculatePayout(userNetBet: number, winningSideTotal: number, totalPool: number): number {
if (winningSideTotal <= 0) return 0;
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 } {
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}%`;
}
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 {
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}`;
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";
switch (type) {
case "tx":
return `${base}/tx/${id}`;
case "account":
return `${base}/account/${id}`;
case "contract":
return `${base}/contract/${id}`;
default:
return base;
case "tx": return base+"/tx/"+id;
case "account": return base+"/account/"+id;
case "contract": return base+"/contract/"+id;
default: return base;
}
}