Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 61 additions & 6 deletions app/client/components/ui/select.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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}
Expand Down
35 changes: 35 additions & 0 deletions app/client/lib/__tests__/datetime.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import {
DEFAULT_TIME_FORMAT,
formatDate,
formatDateTime,
formatDateWithMonth,
Expand Down Expand Up @@ -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");
});
});
150 changes: 100 additions & 50 deletions app/client/lib/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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],
);
}
3 changes: 3 additions & 0 deletions app/client/modules/auth/routes/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading