mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-06-16 12:32:13 -04:00
refactor(datetime): share formatter utilities across runtimes (#980)
This commit is contained in:
@@ -1,204 +1,15 @@
|
||||
import { formatDistanceToNow, isValid } from "date-fns";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
|
||||
import { rawFormatters, type DateInput } from "~/lib/datetime";
|
||||
|
||||
export type DateInput = Date | string | number | null | undefined;
|
||||
|
||||
export const DATE_FORMATS = ["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"] as const;
|
||||
export type DateFormatPreference = (typeof DATE_FORMATS)[number];
|
||||
const DEFAULT_DATE_FORMAT: DateFormatPreference = "MM/DD/YYYY";
|
||||
|
||||
export const TIME_FORMATS = ["12h", "24h"] as const;
|
||||
export type TimeFormatPreference = (typeof TIME_FORMATS)[number];
|
||||
export const DEFAULT_TIME_FORMAT: TimeFormatPreference = "12h";
|
||||
|
||||
const BROWSER_PREFERENCE_SAMPLE_DATE = new Date(Date.UTC(2006, 0, 2, 15, 4, 5));
|
||||
|
||||
const DATE_PART_ORDERS = {
|
||||
"MM/DD/YYYY": ["month", "day", "year"],
|
||||
"DD/MM/YYYY": ["day", "month", "year"],
|
||||
"YYYY/MM/DD": ["year", "month", "day"],
|
||||
} as const;
|
||||
|
||||
const SHORT_DATE_PART_ORDERS = {
|
||||
"MM/DD/YYYY": ["month", "day"],
|
||||
"DD/MM/YYYY": ["day", "month"],
|
||||
"YYYY/MM/DD": ["month", "day"],
|
||||
} as const;
|
||||
|
||||
type DateFormatOptions = {
|
||||
locale?: string | string[];
|
||||
timeZone?: string;
|
||||
dateFormat?: DateFormatPreference;
|
||||
timeFormat?: TimeFormatPreference;
|
||||
};
|
||||
|
||||
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
|
||||
if (!date) return "Never";
|
||||
|
||||
const parsedDate = new Date(date);
|
||||
if (!isValid(parsedDate)) return "Invalid Date";
|
||||
|
||||
return formatter(parsedDate);
|
||||
}
|
||||
|
||||
function getDateTimeFormat(
|
||||
locale: DateFormatOptions["locale"],
|
||||
timeZone: DateFormatOptions["timeZone"],
|
||||
options: Intl.DateTimeFormatOptions,
|
||||
) {
|
||||
return Intl.DateTimeFormat(locale, {
|
||||
...options,
|
||||
timeZone,
|
||||
});
|
||||
}
|
||||
|
||||
function getRequiredPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes) {
|
||||
const value = parts.find((part) => part.type === type)?.value;
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Missing ${type} in formatted date`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatConfiguredDate(date: Date, options: DateFormatOptions, includeYear: boolean) {
|
||||
const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT;
|
||||
const safeDateFormat = DATE_FORMATS.includes(dateFormat) ? dateFormat : DEFAULT_DATE_FORMAT;
|
||||
const parts = getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).formatToParts(date);
|
||||
const values = {
|
||||
month: getRequiredPart(parts, "month"),
|
||||
day: getRequiredPart(parts, "day"),
|
||||
year: getRequiredPart(parts, "year"),
|
||||
};
|
||||
const order = includeYear ? DATE_PART_ORDERS[safeDateFormat] : SHORT_DATE_PART_ORDERS[safeDateFormat];
|
||||
|
||||
return order.map((part) => values[part]).join("/");
|
||||
}
|
||||
|
||||
function formatConfiguredDateWithMonth(date: Date, options: DateFormatOptions) {
|
||||
const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT;
|
||||
const parts = getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).formatToParts(date);
|
||||
const month = getRequiredPart(parts, "month");
|
||||
const day = getRequiredPart(parts, "day");
|
||||
const year = getRequiredPart(parts, "year");
|
||||
|
||||
if (dateFormat === "DD/MM/YYYY") {
|
||||
return `${day} ${month} ${year}`;
|
||||
}
|
||||
|
||||
if (dateFormat === "YYYY/MM/DD") {
|
||||
return `${year} ${month} ${day}`;
|
||||
}
|
||||
|
||||
return `${month} ${day}, ${year}`;
|
||||
}
|
||||
|
||||
function formatConfiguredTime(date: Date, options: DateFormatOptions) {
|
||||
return getDateTimeFormat(options.locale, options.timeZone, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: (options.timeFormat ?? DEFAULT_TIME_FORMAT) === "12h",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function inferDateTimePreferences(locale?: string) {
|
||||
const dateOrder = getDateTimeFormat(locale, undefined, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
.formatToParts(BROWSER_PREFERENCE_SAMPLE_DATE)
|
||||
.flatMap((part) => {
|
||||
if (part.type === "year" || part.type === "month" || part.type === "day") {
|
||||
return [part.type];
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
.join("/");
|
||||
|
||||
let dateFormat = DEFAULT_DATE_FORMAT;
|
||||
|
||||
if (dateOrder === "day/month/year") {
|
||||
dateFormat = "DD/MM/YYYY";
|
||||
} else if (dateOrder === "year/month/day") {
|
||||
dateFormat = "YYYY/MM/DD";
|
||||
}
|
||||
|
||||
let timeFormat: TimeFormatPreference = "12h";
|
||||
const hour12 = getDateTimeFormat(locale, undefined, { hour: "numeric" }).resolvedOptions().hour12;
|
||||
|
||||
if (hour12 === false) {
|
||||
timeFormat = "24h";
|
||||
}
|
||||
|
||||
return {
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
};
|
||||
}
|
||||
|
||||
// 01/10/2026, 2:30 PM
|
||||
function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(
|
||||
date,
|
||||
(validDate) => `${formatConfiguredDate(validDate, options, true)}, ${formatConfiguredTime(validDate, options)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Jan 10, 2026
|
||||
function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) => formatConfiguredDateWithMonth(validDate, options));
|
||||
}
|
||||
|
||||
// 01/10/2026
|
||||
function formatDate(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, true));
|
||||
}
|
||||
|
||||
// 1/10
|
||||
function formatShortDate(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, false));
|
||||
}
|
||||
|
||||
// 01/10, 2:30 PM
|
||||
function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(
|
||||
date,
|
||||
(validDate) => `${formatConfiguredDate(validDate, options, false)}, ${formatConfiguredTime(validDate, options)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2:30 PM
|
||||
function formatTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) => formatConfiguredTime(validDate, options));
|
||||
}
|
||||
|
||||
// 5 minutes ago
|
||||
function formatTimeAgo(date: DateInput, now = Date.now()): string {
|
||||
return formatValidDate(date, (validDate) => {
|
||||
if (Math.abs(now - validDate.getTime()) < 120_000) {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
const timeAgo = formatDistanceToNow(validDate, {
|
||||
addSuffix: true,
|
||||
includeSeconds: true,
|
||||
});
|
||||
|
||||
return timeAgo.replace("about ", "").replace("over ", "").replace("almost ", "").replace("less than ", "");
|
||||
});
|
||||
}
|
||||
export {
|
||||
DATE_FORMATS,
|
||||
DEFAULT_TIME_FORMAT,
|
||||
inferDateTimePreferences,
|
||||
rawFormatters,
|
||||
TIME_FORMATS,
|
||||
} from "~/lib/datetime";
|
||||
export type { DateFormatPreference, DateInput, TimeFormatPreference } from "~/lib/datetime";
|
||||
|
||||
export function useTimeFormat() {
|
||||
const { locale, timeZone, dateFormat, timeFormat, now } = useRootLoaderData();
|
||||
@@ -211,24 +22,30 @@ export function useTimeFormat() {
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
formatDateTime: (date: DateInput) => formatDateTime(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatDateWithMonth: (date: DateInput) => formatDateWithMonth(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatDate: (date: DateInput) => formatDate(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatShortDate: (date: DateInput) => formatShortDate(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatShortDateTime: (date: DateInput) => formatShortDateTime(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatTime: (date: DateInput) => formatTime(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatTimeAgo: (date: DateInput) => formatTimeAgo(date, currentNow),
|
||||
formatDateTime: (date: DateInput) =>
|
||||
rawFormatters.formatDateTime(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatDateWithMonth: (date: DateInput) =>
|
||||
rawFormatters.formatDateWithMonth(date, {
|
||||
locale,
|
||||
timeZone,
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
}),
|
||||
formatDate: (date: DateInput) =>
|
||||
rawFormatters.formatDate(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatShortDate: (date: DateInput) =>
|
||||
rawFormatters.formatShortDate(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatShortDateTime: (date: DateInput) =>
|
||||
rawFormatters.formatShortDateTime(date, {
|
||||
locale,
|
||||
timeZone,
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
}),
|
||||
formatTime: (date: DateInput) =>
|
||||
rawFormatters.formatTime(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatTimeAgo: (date: DateInput) => rawFormatters.formatTimeAgo(date, currentNow),
|
||||
}),
|
||||
[locale, timeZone, currentNow, dateFormat, timeFormat],
|
||||
);
|
||||
}
|
||||
|
||||
export const rawFormatters = {
|
||||
formatDateTime,
|
||||
formatDateWithMonth,
|
||||
formatDate,
|
||||
formatShortDate,
|
||||
formatShortDateTime,
|
||||
formatTime,
|
||||
formatTimeAgo,
|
||||
};
|
||||
|
||||
210
app/lib/datetime.ts
Normal file
210
app/lib/datetime.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { formatDistanceToNow, isValid } from "date-fns";
|
||||
|
||||
export type DateInput = Date | string | number | null | undefined;
|
||||
|
||||
export const DATE_FORMATS = ["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"] as const;
|
||||
export type DateFormatPreference = (typeof DATE_FORMATS)[number];
|
||||
const DEFAULT_DATE_FORMAT: DateFormatPreference = "MM/DD/YYYY";
|
||||
|
||||
export const TIME_FORMATS = ["12h", "24h"] as const;
|
||||
export type TimeFormatPreference = (typeof TIME_FORMATS)[number];
|
||||
export const DEFAULT_TIME_FORMAT: TimeFormatPreference = "12h";
|
||||
|
||||
const BROWSER_PREFERENCE_SAMPLE_DATE = new Date(Date.UTC(2006, 0, 2, 15, 4, 5));
|
||||
|
||||
const DATE_PART_ORDERS = {
|
||||
"MM/DD/YYYY": ["month", "day", "year"],
|
||||
"DD/MM/YYYY": ["day", "month", "year"],
|
||||
"YYYY/MM/DD": ["year", "month", "day"],
|
||||
} as const;
|
||||
|
||||
const SHORT_DATE_PART_ORDERS = {
|
||||
"MM/DD/YYYY": ["month", "day"],
|
||||
"DD/MM/YYYY": ["day", "month"],
|
||||
"YYYY/MM/DD": ["month", "day"],
|
||||
} as const;
|
||||
|
||||
type DateFormatOptions = {
|
||||
locale?: string | string[];
|
||||
timeZone?: string;
|
||||
dateFormat?: DateFormatPreference;
|
||||
timeFormat?: TimeFormatPreference;
|
||||
};
|
||||
|
||||
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
|
||||
if (!date) return "Never";
|
||||
|
||||
const parsedDate = new Date(date);
|
||||
if (!isValid(parsedDate)) return "Invalid Date";
|
||||
|
||||
return formatter(parsedDate);
|
||||
}
|
||||
|
||||
function getDateTimeFormat(
|
||||
locale: DateFormatOptions["locale"],
|
||||
timeZone: DateFormatOptions["timeZone"],
|
||||
options: Intl.DateTimeFormatOptions,
|
||||
) {
|
||||
return Intl.DateTimeFormat(locale, {
|
||||
...options,
|
||||
timeZone,
|
||||
});
|
||||
}
|
||||
|
||||
function getRequiredPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes) {
|
||||
const value = parts.find((part) => part.type === type)?.value;
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Missing ${type} in formatted date`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatConfiguredDate(date: Date, options: DateFormatOptions, includeYear: boolean) {
|
||||
const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT;
|
||||
const safeDateFormat = DATE_FORMATS.includes(dateFormat) ? dateFormat : DEFAULT_DATE_FORMAT;
|
||||
const parts = getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).formatToParts(date);
|
||||
const values = {
|
||||
month: getRequiredPart(parts, "month"),
|
||||
day: getRequiredPart(parts, "day"),
|
||||
year: getRequiredPart(parts, "year"),
|
||||
};
|
||||
const order = includeYear ? DATE_PART_ORDERS[safeDateFormat] : SHORT_DATE_PART_ORDERS[safeDateFormat];
|
||||
|
||||
return order.map((part) => values[part]).join("/");
|
||||
}
|
||||
|
||||
function formatConfiguredDateWithMonth(date: Date, options: DateFormatOptions) {
|
||||
const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT;
|
||||
const parts = getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).formatToParts(date);
|
||||
const month = getRequiredPart(parts, "month");
|
||||
const day = getRequiredPart(parts, "day");
|
||||
const year = getRequiredPart(parts, "year");
|
||||
|
||||
if (dateFormat === "DD/MM/YYYY") {
|
||||
return `${day} ${month} ${year}`;
|
||||
}
|
||||
|
||||
if (dateFormat === "YYYY/MM/DD") {
|
||||
return `${year} ${month} ${day}`;
|
||||
}
|
||||
|
||||
return `${month} ${day}, ${year}`;
|
||||
}
|
||||
|
||||
function formatConfiguredTime(date: Date, options: DateFormatOptions) {
|
||||
return getDateTimeFormat(options.locale, options.timeZone, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: (options.timeFormat ?? DEFAULT_TIME_FORMAT) === "12h",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function inferDateTimePreferences(locale?: string) {
|
||||
const dateOrder = getDateTimeFormat(locale, undefined, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
.formatToParts(BROWSER_PREFERENCE_SAMPLE_DATE)
|
||||
.flatMap((part) => {
|
||||
if (part.type === "year" || part.type === "month" || part.type === "day") {
|
||||
return [part.type];
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
.join("/");
|
||||
|
||||
let dateFormat = DEFAULT_DATE_FORMAT;
|
||||
|
||||
if (dateOrder === "day/month/year") {
|
||||
dateFormat = "DD/MM/YYYY";
|
||||
} else if (dateOrder === "year/month/day") {
|
||||
dateFormat = "YYYY/MM/DD";
|
||||
}
|
||||
|
||||
let timeFormat: TimeFormatPreference = "12h";
|
||||
const hour12 = getDateTimeFormat(locale, undefined, { hour: "numeric" }).resolvedOptions().hour12;
|
||||
|
||||
if (hour12 === false) {
|
||||
timeFormat = "24h";
|
||||
}
|
||||
|
||||
return {
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
};
|
||||
}
|
||||
|
||||
// 01/10/2026, 2:30 PM
|
||||
function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(
|
||||
date,
|
||||
(validDate) => `${formatConfiguredDate(validDate, options, true)}, ${formatConfiguredTime(validDate, options)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Jan 10, 2026
|
||||
function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) => formatConfiguredDateWithMonth(validDate, options));
|
||||
}
|
||||
|
||||
// 01/10/2026
|
||||
function formatDate(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, true));
|
||||
}
|
||||
|
||||
// 1/10
|
||||
function formatShortDate(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, false));
|
||||
}
|
||||
|
||||
// 01/10, 2:30 PM
|
||||
function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(
|
||||
date,
|
||||
(validDate) =>
|
||||
`${formatConfiguredDate(validDate, options, false)}, ${formatConfiguredTime(validDate, options)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2:30 PM
|
||||
function formatTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) => formatConfiguredTime(validDate, options));
|
||||
}
|
||||
|
||||
// 5 minutes ago
|
||||
function formatTimeAgo(date: DateInput, now = Date.now()): string {
|
||||
return formatValidDate(date, (validDate) => {
|
||||
if (Math.abs(now - validDate.getTime()) < 120_000) {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
const timeAgo = formatDistanceToNow(validDate, {
|
||||
addSuffix: true,
|
||||
includeSeconds: true,
|
||||
});
|
||||
|
||||
return timeAgo.replace("about ", "").replace("over ", "").replace("almost ", "").replace("less than ", "");
|
||||
});
|
||||
}
|
||||
|
||||
export const rawFormatters = {
|
||||
formatDateTime,
|
||||
formatDateWithMonth,
|
||||
formatDate,
|
||||
formatShortDate,
|
||||
formatShortDateTime,
|
||||
formatTime,
|
||||
formatTimeAgo,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
||||
import { THEME_COOKIE_NAME } from "~/client/components/theme-provider";
|
||||
import type { DateFormatPreference, TimeFormatPreference } from "~/client/lib/datetime";
|
||||
import type { DateFormatPreference, TimeFormatPreference } from "~/lib/datetime";
|
||||
import { getLocaleFromAcceptLanguage } from "~/server/lib/accept-language";
|
||||
import { auth } from "~/server/lib/auth";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user