const DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
{ amount: 60, unit: "seconds" },
{ amount: 60, unit: "minutes" },
{ amount: 24, unit: "hours" },
{ amount: 7, unit: "days" },
{ amount: 4.34524, unit: "weeks" },
{ amount: 12, unit: "months" },
{ amount: Number.POSITIVE_INFINITY, unit: "years" }
];
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const dtf = new Intl.DateTimeFormat("en", { year: "numeric", month: "short", day: "numeric" });
const dtfMonthDay = new Intl.DateTimeFormat("en", { month: "short", day: "numeric" });
const dtfUtc = new Intl.DateTimeFormat("en", {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC"
});
// the appview's longTimeFmt ("Jan 2, 2006, 3:04 PM MST")
const dtfFull = new Intl.DateTimeFormat("en", {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short"
});
// "3 days ago", "yesterday", etc.
// a future time is clock skew between machines, so it reads as now
export const relativeTime = (input: string | Date, now: Date = new Date()): string => {
const date = typeof input === "string" ? new Date(input) : input;
if (Number.isNaN(date.getTime())) return "";
if (date.getTime() === 0) return "at the beginning of time";
let duration = Math.min(date.getTime() - now.getTime(), 0) / 1000;
for (const division of DIVISIONS) {
if (Math.abs(duration) < division.amount)
return rtf.format(Math.round(duration), division.unit);
duration /= division.amount;
}
return rtf.format(Math.round(duration), "years");
};
export const compactRelativeTime = (input: string | Date, now: Date = new Date()): string => {
const date = typeof input === "string" ? new Date(input) : input;
if (Number.isNaN(date.getTime())) return "";
let duration = (now.getTime() - date.getTime()) / 1000;
const suffix = "ago";
if (duration < 1) return "now";
if (duration < 60) return `${Math.floor(duration)}s ${suffix}`;
duration /= 60;
if (duration < 60) return `${Math.floor(duration)}m ${suffix}`;
duration /= 60;
if (duration < 24) return `${Math.floor(duration)}h ${suffix}`;
duration /= 24;
if (duration < 7) return `${Math.floor(duration)}d ${suffix}`;
duration /= 7;
if (duration < 4.34524) return `${Math.floor(duration)}w ${suffix}`;
duration /= 4.34524;
if (duration < 12) return `${Math.floor(duration)}mo ${suffix}`;
duration /= 12;
return `${Math.floor(duration)}y ${suffix}`;
};
export const formatDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms < 0) return "";
const total = Math.floor(ms / 1000);
const chunks: [number, string][] = [
[Math.floor(total / 86400), "d"],
[Math.floor(total / 3600) % 24, "h"],
[Math.floor(total / 60) % 60, "m"],
[total % 60, "s"]
];
const parts = chunks
.filter(([amount]) => amount !== 0)
.map(([amount, unit]) => `${amount}${unit}`);
return parts.length > 0 ? parts.join(" ") : "0s";
};
export const formatDate = (input: string | Date): string => {
const date = typeof input === "string" ? new Date(input) : input;
return Number.isNaN(date.getTime()) ? "" : dtf.format(date);
};
export const formatShortDate = (input: string | Date, now: Date = new Date()): string => {
const date = typeof input === "string" ? new Date(input) : input;
if (Number.isNaN(date.getTime())) return "";
return date.getFullYear() === now.getFullYear() ? dtfMonthDay.format(date) : dtf.format(date);
};
export const formatCalendarDate = (input: string): string => {
const date = new Date(input);
return Number.isNaN(date.getTime()) ? "" : dtfUtc.format(date);
};
export const formatDateTime = (input: string | Date): string => {
const date = typeof input === "string" ? new Date(input) : input;
return Number.isNaN(date.getTime()) ? "" : dtfFull.format(date);
};
export const formatCount = (n: number | undefined | null): string => {
if (n == null) return "";
if (n < 1000) return String(n);
const [scaled, suffix] = n < 1_000_000 ? [n / 1000, "k"] : [n / 1_000_000, "m"];
// truncate so 999,999 reads as 999.9k rather than rounding up to 1000k
return `${Math.floor(scaled * 10) / 10}${suffix}`;
};
export const uniquePrefixLength = (id: string, ids: string[]): number => {
let shared = 0;
for (const other of ids) {
if (other === id) continue;
let i = 0;
while (i < id.length && id[i] === other[i]) i++;
shared = Math.max(shared, i);
}
return Math.min(id.length, shared + 1);
};
const pluralRules = new Intl.PluralRules("en");
// the noun alone, for markup that renders the number separately
export const plural = (n: number, one: string, other: string): string =>
pluralRules.select(n) === "one" ? one : other;
// "1 follower", "1.2k followers"
export const pluralize = (n: number, one: string, other: string): string =>
`${formatCount(n)} ${plural(n, one, other)}`;
export const sentence = (text: string): string => {
const trimmed = text.trim();
const capitalized = trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
return /[.!?]$/.test(capitalized) ? capitalized : `${capitalized}.`;
};