75 lines
2.4 KiB
TypeScript
75 lines
2.4 KiB
TypeScript
const number = new Intl.NumberFormat("en-US");
|
||
|
||
/** 12.345 -> "12.345", 83.2 -> "1:23.200", 3723.5 -> "1:02:03.500". */
|
||
export function formatTime(seconds: number): string {
|
||
const ms = Math.round(seconds * 1000);
|
||
const h = Math.floor(ms / 3_600_000);
|
||
const m = Math.floor((ms % 3_600_000) / 60_000);
|
||
const s = Math.floor((ms % 60_000) / 1000);
|
||
const rest = String(ms % 1000).padStart(3, "0");
|
||
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${rest}`;
|
||
if (m > 0) return `${m}:${String(s).padStart(2, "0")}.${rest}`;
|
||
return `${s}.${rest}`;
|
||
}
|
||
|
||
/** Signed difference, e.g. "+1.204" or "−0.500". */
|
||
export function formatDelta(seconds: number): string {
|
||
const sign = seconds < 0 ? "−" : "+";
|
||
return sign + formatTime(Math.abs(seconds));
|
||
}
|
||
|
||
export function formatNumber(n: number): string {
|
||
return number.format(n);
|
||
}
|
||
|
||
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
||
["year", 31_536_000],
|
||
["month", 2_592_000],
|
||
["week", 604_800],
|
||
["day", 86_400],
|
||
["hour", 3_600],
|
||
["minute", 60],
|
||
];
|
||
|
||
/** Unix seconds -> "3 weeks ago". */
|
||
export function timeAgo(unixSeconds: number, now = Date.now() / 1000): string {
|
||
// Never show "in 3 minutes" when a server clock is slightly ahead.
|
||
const diff = Math.min(0, unixSeconds - now);
|
||
for (const [unit, size] of UNITS) {
|
||
if (Math.abs(diff) >= size) return rtf.format(Math.round(diff / size), unit);
|
||
}
|
||
return "just now";
|
||
}
|
||
|
||
/** Unix seconds -> "27 Sep 2026, 14:03:09" in the viewer's own time zone. */
|
||
export function formatDate(unixSeconds: number): string {
|
||
return new Date(unixSeconds * 1000).toLocaleString("en-GB", {
|
||
day: "numeric",
|
||
month: "short",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
hour12: false,
|
||
});
|
||
}
|
||
|
||
export function serverLabel(tag: string): string {
|
||
return tag.toUpperCase();
|
||
}
|
||
|
||
/** Which bonus band a position is in, for the label next to the multiplier. */
|
||
export function bonusLabel(position: number, completions: number): string | null {
|
||
if (completions < 100) return null;
|
||
const x = (position - 1) / completions;
|
||
if (x < 0.01) return "Top 1%";
|
||
if (x < 0.05) return "Top 5%";
|
||
return null;
|
||
}
|
||
|
||
/** How many players on a board get points (half from 200 completions on). */
|
||
export function pointedCount(completions: number): number {
|
||
return completions < 200 ? completions : Math.floor(completions / 2);
|
||
}
|