181 lines
4.9 KiB
TypeScript
181 lines
4.9 KiB
TypeScript
import dayjs from "dayjs";
|
|
import duration from "dayjs/plugin/duration";
|
|
import utc from "dayjs/plugin/utc";
|
|
import timezone from "dayjs/plugin/timezone";
|
|
import customParseFormat from "dayjs/plugin/customParseFormat";
|
|
import prettyMilliseconds from "pretty-ms";
|
|
|
|
dayjs.extend(utc);
|
|
dayjs.extend(timezone);
|
|
dayjs.extend(customParseFormat);
|
|
dayjs.extend(duration);
|
|
|
|
export const MS_PER_SECOND = 1000;
|
|
export const MS_PER_MINUTE = 60 * MS_PER_SECOND;
|
|
export const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
export const MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
export const MS_PER_WEEK = MS_PER_DAY * 7;
|
|
|
|
const resolveSystemTimezone = (): string => {
|
|
try {
|
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
} catch {
|
|
return "UTC";
|
|
}
|
|
};
|
|
|
|
export const SYSTEM_TIMEZONE = resolveSystemTimezone();
|
|
|
|
export const resolveTimezone = (preferred?: string | null): string =>
|
|
preferred?.trim() || SYSTEM_TIMEZONE;
|
|
|
|
export const formatDateWithTz = (
|
|
timestamp: string,
|
|
format: string,
|
|
timezone?: string
|
|
) => {
|
|
if (!timestamp) {
|
|
return "Unknown time";
|
|
}
|
|
const tz = resolveTimezone(timezone);
|
|
try {
|
|
return dayjs(timestamp).tz(tz).format(format);
|
|
} catch {
|
|
return dayjs(timestamp).utc().format(format);
|
|
}
|
|
};
|
|
|
|
export const tickDateFormatLookup = (range: string) => {
|
|
const tickFormatLookup: Record<string, string> = {
|
|
recent: "h:mm A",
|
|
day: "h:mm A",
|
|
week: "MM/D, h:mm A",
|
|
month: "ddd. M/D",
|
|
};
|
|
const format = tickFormatLookup[range];
|
|
if (format === undefined) {
|
|
return "";
|
|
}
|
|
return format;
|
|
};
|
|
|
|
export const tooltipDateFormatLookup = (range: string) => {
|
|
const dateFormatLookup: Record<string, string> = {
|
|
recent: "ddd. MMMM D, YYYY, hh:mm A",
|
|
day: "ddd. MMMM D, YYYY, hh:mm A",
|
|
week: "ddd. MMMM D, YYYY, hh:mm A",
|
|
month: "ddd. MMMM D, YYYY",
|
|
};
|
|
const format = dateFormatLookup[range];
|
|
if (format === undefined) {
|
|
return "";
|
|
}
|
|
return format;
|
|
};
|
|
|
|
export const formatTimestamp = (timestamp: string | number | null): string => {
|
|
if (!timestamp) return "-";
|
|
const date = new Date(timestamp);
|
|
return date.toLocaleString();
|
|
};
|
|
|
|
const DISPLAY_UNITS = [MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE, MS_PER_SECOND];
|
|
|
|
// pretty-ms floors seconds-and-above and truncates fractional ms, so values
|
|
// must be rounded to their display unit first to land on the nearest whole
|
|
// unit (53.6 → "54 ms", 1937 → "2 s", 59500 → "1 m").
|
|
const roundToDisplayUnit = (ms: number): number => {
|
|
for (const unit of DISPLAY_UNITS) {
|
|
if (ms >= unit) return Math.round(ms / unit) * unit;
|
|
}
|
|
return ms >= 1 ? Math.round(ms) : ms;
|
|
};
|
|
|
|
export const formatMs = (ms: number, hasSpace: boolean = true): string => {
|
|
const formatted = prettyMilliseconds(roundToDisplayUnit(ms), {
|
|
compact: true,
|
|
formatSubMilliseconds: true,
|
|
secondsDecimalDigits: 0,
|
|
});
|
|
|
|
if (hasSpace) {
|
|
return formatted.replace(/^(\d+)(\D+)$/, "$1 $2");
|
|
}
|
|
return formatted;
|
|
};
|
|
|
|
type DurationUnit = "day" | "hour" | "minute" | "second";
|
|
|
|
interface FormatDurationOptions {
|
|
long?: boolean;
|
|
relative?: boolean;
|
|
hasSpace?: boolean;
|
|
locale?: string;
|
|
}
|
|
|
|
const DURATION_UNITS: Array<{ unit: DurationUnit; milliseconds: number }> = [
|
|
{ unit: "day", milliseconds: MS_PER_DAY },
|
|
{ unit: "hour", milliseconds: MS_PER_HOUR },
|
|
{ unit: "minute", milliseconds: MS_PER_MINUTE },
|
|
{ unit: "second", milliseconds: MS_PER_SECOND },
|
|
];
|
|
|
|
const FALLBACK_DURATION_UNIT = DURATION_UNITS[DURATION_UNITS.length - 1];
|
|
|
|
const getDurationUnit = (ms: number) =>
|
|
DURATION_UNITS.find(({ milliseconds }) => Math.abs(ms) >= milliseconds) ??
|
|
FALLBACK_DURATION_UNIT;
|
|
|
|
const getExactDurationUnit = (ms: number) =>
|
|
DURATION_UNITS.find(({ milliseconds }) => ms !== 0 && ms % milliseconds === 0) ??
|
|
FALLBACK_DURATION_UNIT;
|
|
|
|
export const formatDuration = (
|
|
ms: number,
|
|
{ long = false, relative = false, hasSpace = true, locale }: FormatDurationOptions = {}
|
|
): string => {
|
|
if (relative) {
|
|
const { unit, milliseconds } = getDurationUnit(ms);
|
|
const value = Math.sign(ms) * Math.floor(Math.abs(ms) / milliseconds);
|
|
return new Intl.RelativeTimeFormat(locale, {
|
|
numeric: "always",
|
|
style: long ? "long" : "narrow",
|
|
}).format(-value, unit);
|
|
}
|
|
|
|
if (long) {
|
|
const { unit, milliseconds } = getExactDurationUnit(ms);
|
|
return new Intl.NumberFormat(locale, {
|
|
style: "unit",
|
|
unit,
|
|
unitDisplay: "long",
|
|
}).format(ms / milliseconds);
|
|
}
|
|
|
|
const formatted = prettyMilliseconds(ms, {
|
|
unitCount: 2,
|
|
secondsDecimalDigits: 0,
|
|
millisecondsDecimalDigits: 0,
|
|
});
|
|
|
|
if (hasSpace) {
|
|
return formatted.replace(/^(\d+)(\D+)$/, "$1 $2");
|
|
}
|
|
return formatted;
|
|
};
|
|
|
|
export const enumerateDays = (days: number, timeZone: string): string[] => {
|
|
const formatter = new Intl.DateTimeFormat("en-CA", {
|
|
timeZone,
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
});
|
|
const [year, month, day] = formatter.format(new Date()).split("-").map(Number);
|
|
const todayUtc = Date.UTC(year, month - 1, day);
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
return Array.from({ length: days }, (_, i) =>
|
|
new Date(todayUtc - (days - 1 - i) * DAY_MS).toISOString().slice(0, 10)
|
|
);
|
|
};
|