feat: custom date time format (#718)

This commit is contained in:
Nico 2026-03-29 17:24:54 +02:00 committed by GitHub
parent 2a219ac042
commit f89b8c3b52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2626 additions and 57 deletions

View file

@ -1,19 +1,74 @@
import type * as React from "react";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "~/client/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
const SelectSsrValueContext = React.createContext<{
items: Map<string, string>;
value: string | undefined;
} | null>(null);
function getSelectItemText(children: React.ReactNode): string {
return React.Children.toArray(children)
.map((child) => {
if (typeof child === "string" || typeof child === "number") {
return String(child);
}
if (!React.isValidElement<{ children?: React.ReactNode }>(child)) {
return "";
}
return getSelectItemText(child.props.children);
})
.join("")
.trim();
}
function collectSelectItems(children: React.ReactNode, items = new Map<string, string>()) {
for (const child of React.Children.toArray(children)) {
if (!React.isValidElement<{ children?: React.ReactNode; value?: string }>(child)) {
continue;
}
if ((child.type === SelectItem || child.type === SelectPrimitive.Item) && typeof child.props.value === "string") {
items.set(child.props.value, getSelectItemText(child.props.children));
}
if (child.props.children) {
collectSelectItems(child.props.children, items);
}
}
return items;
}
function Select({ children, value, ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
const items = collectSelectItems(children);
return (
<SelectSsrValueContext.Provider value={{ items, value }}>
<SelectPrimitive.Root data-slot="select" value={value} {...props}>
{children}
</SelectPrimitive.Root>
</SelectSsrValueContext.Provider>
);
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
function SelectValue({ children, ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
const context = React.useContext(SelectSsrValueContext);
const resolvedChildren = children ?? (context?.value ? context.items.get(context.value) : undefined);
return (
<SelectPrimitive.Value data-slot="select-value" {...props}>
{resolvedChildren}
</SelectPrimitive.Value>
);
}
function SelectTrigger({
@ -29,7 +84,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&+select[aria-hidden=true]]:hidden [&:has(+select[aria-hidden=true]:last-child)]:mb-0",
className,
)}
{...props}

View file

@ -1,5 +1,6 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import {
DEFAULT_TIME_FORMAT,
formatDate,
formatDateTime,
formatDateWithMonth,
@ -64,4 +65,38 @@ describe("datetime formatters", () => {
test("formats calendar values with an explicit locale and timezone", () => {
expect(formatShortDateTime(sampleDate, { locale: "en-US", timeZone: "UTC" })).toBe("1/10, 2:30 PM");
});
test.each([
["MM/DD/YYYY", "1/10/2026"],
["DD/MM/YYYY", "10/1/2026"],
["YYYY/MM/DD", "2026/1/10"],
] as const)("formats numeric dates with %s order", (dateFormat, expected) => {
expect(formatDate(sampleDate, { locale: "en-US", timeZone: "UTC", dateFormat })).toBe(expected);
});
test.each([
["MM/DD/YYYY", "Jan 10, 2026"],
["DD/MM/YYYY", "10 Jan 2026"],
["YYYY/MM/DD", "2026 Jan 10"],
] as const)("formats month dates with %s order", (dateFormat, expected) => {
expect(formatDateWithMonth(sampleDate, { locale: "en-US", timeZone: "UTC", dateFormat })).toBe(expected);
});
test.each([
[DEFAULT_TIME_FORMAT, "2:30 PM"],
["24h", "14:30"],
] as const)("formats times with %s clock", (timeFormat, expected) => {
expect(formatTime(sampleDate, { locale: "en-US", timeZone: "UTC", timeFormat })).toBe(expected);
});
test("formats combined values with custom date and time preferences", () => {
expect(
formatDateTime(sampleDate, {
locale: "en-US",
timeZone: "UTC",
dateFormat: "DD/MM/YYYY",
timeFormat: "24h",
}),
).toBe("10/1/2026, 14:30");
});
});

View file

@ -4,9 +4,31 @@ import { Route as RootRoute } from "~/routes/__root";
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];
export 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 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 {
@ -29,71 +51,98 @@ function getDateTimeFormat(
});
}
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: "numeric",
day: "numeric",
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);
}
// 1/10/2026, 2:30 PM
export function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) =>
getDateTimeFormat(options.locale, options.timeZone, {
month: "numeric",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
}).format(validDate),
return formatValidDate(
date,
(validDate) => `${formatConfiguredDate(validDate, options, true)}, ${formatConfiguredTime(validDate, options)}`,
);
}
// Jan 10, 2026
export function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) =>
getDateTimeFormat(options.locale, options.timeZone, {
month: "short",
day: "numeric",
year: "numeric",
}).format(validDate),
);
return formatValidDate(date, (validDate) => formatConfiguredDateWithMonth(validDate, options));
}
// 1/10/2026
export function formatDate(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) =>
getDateTimeFormat(options.locale, options.timeZone, {
month: "numeric",
day: "numeric",
year: "numeric",
}).format(validDate),
);
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, true));
}
// 1/10
export function formatShortDate(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) =>
getDateTimeFormat(options.locale, options.timeZone, {
month: "numeric",
day: "numeric",
}).format(validDate),
);
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, false));
}
// 1/10, 2:30 PM
export function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) =>
getDateTimeFormat(options.locale, options.timeZone, {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}).format(validDate),
return formatValidDate(
date,
(validDate) => `${formatConfiguredDate(validDate, options, false)}, ${formatConfiguredTime(validDate, options)}`,
);
}
// 2:30 PM
export function formatTime(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) =>
getDateTimeFormat(options.locale, options.timeZone, {
hour: "numeric",
minute: "numeric",
}).format(validDate),
);
return formatValidDate(date, (validDate) => formatConfiguredTime(validDate, options));
}
// 5 minutes ago
@ -113,23 +162,24 @@ export function formatTimeAgo(date: DateInput, now = Date.now()): string {
}
export function useTimeFormat() {
const { locale, timeZone, now } = RootRoute.useLoaderData();
const { locale, timeZone, dateFormat, timeFormat, now } = RootRoute.useLoaderData();
const [currentNow, setCurrentNow] = useState(now);
useEffect(() => {
setCurrentNow(Date.now());
const nextNow = Date.now();
setCurrentNow(nextNow === now ? now : nextNow);
}, [now]);
return useMemo(
() => ({
formatDateTime: (date: DateInput) => formatDateTime(date, { locale, timeZone }),
formatDateWithMonth: (date: DateInput) => formatDateWithMonth(date, { locale, timeZone }),
formatDate: (date: DateInput) => formatDate(date, { locale, timeZone }),
formatShortDate: (date: DateInput) => formatShortDate(date, { locale, timeZone }),
formatShortDateTime: (date: DateInput) => formatShortDateTime(date, { locale, timeZone }),
formatTime: (date: DateInput) => formatTime(date, { locale, timeZone }),
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),
}),
[locale, timeZone, currentNow],
[locale, timeZone, currentNow, dateFormat, timeFormat],
);
}

View file

@ -20,6 +20,7 @@ import { useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username";
import { z } from "zod";
import { DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from "~/client/lib/datetime";
export const clientMiddleware = [authMiddleware];
@ -60,6 +61,8 @@ export function OnboardingPage() {
const { data, error } = await authClient.signUp.email({
username: normalizeUsername(values.username),
dateFormat: DEFAULT_DATE_FORMAT,
timeFormat: DEFAULT_TIME_FORMAT,
password: values.password,
email: values.email.toLowerCase().trim(),
name: values.username,

View file

@ -17,10 +17,19 @@ import {
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import { authClient } from "~/client/lib/auth-client";
import {
DATE_FORMATS,
type DateFormatPreference,
formatDateTime,
TIME_FORMATS,
type TimeFormatPreference,
} from "~/client/lib/datetime";
import { logger } from "~/client/lib/logger";
import { type AppContext } from "~/context";
import { Route as RootRoute } from "~/routes/__root";
import { TwoFactorSection } from "../components/two-factor-section";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
@ -41,6 +50,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const [downloadPassword, setDownloadPassword] = useState("");
const [isChangingPassword, setIsChangingPassword] = useState(false);
const { locale, dateFormat, timeFormat } = RootRoute.useLoaderData();
const { tab } = useSearch({ from: "/(dashboard)/settings/" });
const activeTab = tab || "account";
@ -48,6 +58,12 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
const navigate = useNavigate();
const { activeMember, activeOrganization } = useOrganizationContext();
const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin";
const dateTimePreview = formatDateTime("2026-01-10T14:30:00.000Z", {
locale,
timeZone: "UTC",
dateFormat,
timeFormat,
});
const handleLogout = async () => {
await authClient.signOut({
@ -141,6 +157,42 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
});
};
const handleDateTimeFormatChange = async (
nextDateFormat: DateFormatPreference,
nextTimeFormat: TimeFormatPreference,
) => {
await authClient.updateUser({
dateFormat: nextDateFormat,
timeFormat: nextTimeFormat,
fetchOptions: {
onError: ({ error }) => {
toast.error("Failed to update date and time format", {
description: error.message,
});
},
onSuccess: () => {
window.location.reload();
},
},
});
};
const handleDateFormatChange = async (nextDateFormat: DateFormatPreference) => {
if (nextDateFormat === dateFormat) {
return;
}
await handleDateTimeFormatChange(nextDateFormat, timeFormat);
};
const handleTimeFormatChange = async (nextTimeFormat: TimeFormatPreference) => {
if (nextTimeFormat === timeFormat) {
return;
}
await handleDateTimeFormatChange(dateFormat, nextTimeFormat);
};
const onTabChange = (value: string) => {
void navigate({ to: ".", search: () => ({ tab: value }) });
};
@ -174,6 +226,59 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
</div>
</CardContent>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<SettingsIcon className="size-5" />
Date and Time Format
</CardTitle>
<CardDescription className="mt-1.5">
Choose how dates and times are shown throughout the app
</CardDescription>
</div>
<CardContent className="p-6">
<div className="space-y-4 max-w-2xl">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="date-format">Date format</Label>
<Select
value={dateFormat}
onValueChange={(value) => void handleDateFormatChange(value as DateFormatPreference)}
>
<SelectTrigger id="date-format">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="time-format">Time format</Label>
<Select
value={timeFormat}
onValueChange={(value) => void handleTimeFormatChange(value as TimeFormatPreference)}
>
<SelectTrigger id="time-format">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_FORMATS.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="text-sm text-muted-foreground">Preview: {dateTimePreview}</p>
</div>
</CardContent>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-5" />

View file

@ -4,6 +4,8 @@ type User = {
username: string;
name: string;
hasDownloadedResticPassword: boolean;
dateFormat: string;
timeFormat: string;
twoFactorEnabled?: boolean | null;
role?: string | null | undefined;
};

View file

@ -0,0 +1,2 @@
ALTER TABLE `users_table` ADD `date_format` text DEFAULT 'MM/DD/YYYY' NOT NULL;--> statement-breakpoint
ALTER TABLE `users_table` ADD `time_format` text DEFAULT '12h' NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,8 @@ import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-prov
import { createServerFn } from "@tanstack/react-start";
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
import { isAuthRoute } from "~/lib/auth-routes";
import { auth } from "~/server/lib/auth";
import type { DateFormatPreference, TimeFormatPreference } from "~/client/lib/datetime";
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
const themeCookie = getCookie(THEME_COOKIE_NAME);
@ -17,11 +19,15 @@ const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
});
const fetchTimeConfig = createServerFn({ method: "GET" }).handler(async () => {
const acceptLanguage = getRequestHeaders().get("accept-language");
const headers = getRequestHeaders();
const acceptLanguage = headers.get("accept-language");
const session = await auth.api.getSession({ headers });
return {
locale: (acceptLanguage?.split(",")[0] || "en-US") as string,
timeZone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
dateFormat: (session?.user.dateFormat ?? "MM/DD/YYYY") as DateFormatPreference,
timeFormat: (session?.user.timeFormat ?? "12h") as TimeFormatPreference,
now: Date.now(),
};
});

View file

@ -21,6 +21,8 @@ export const usersTable = sqliteTable("users_table", {
username: text().notNull().unique(),
passwordHash: text("password_hash"),
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false),
dateFormat: text("date_format").notNull().default("MM/DD/YYYY"),
timeFormat: text("time_format").notNull().default("12h"),
createdAt: int("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),

View file

@ -138,6 +138,14 @@ export const auth = betterAuth({
type: "boolean",
returned: true,
},
dateFormat: {
type: "string",
returned: true,
},
timeFormat: {
type: "string",
returned: true,
},
},
},
session: {