Skip to content
Open
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
38 changes: 38 additions & 0 deletions frontend/src/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
displayXLM,
explorerUrl,
formatDate,
formatEventTime,
formatTime,
formatXLM,
isValidAmount,
Expand Down Expand Up @@ -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", () => {
Expand Down
168 changes: 39 additions & 129 deletions frontend/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 =
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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).
*/
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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;
}
}
}